Add monthly report export as Markdown and CSV

This commit is contained in:
2026-09-14 15:07:49 +02:00
parent 75e47d7f40
commit b09b536b49
3 changed files with 142 additions and 3 deletions
+140 -3
View File
@@ -1,9 +1,12 @@
import calendar
import csv
import io
import logging
import os
import secrets
import shutil
import time
from datetime import date, datetime
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Annotated
from urllib.parse import urlencode
@@ -245,17 +248,151 @@ def accept_day(day: str, request: Request, start: Annotated[str, Form()] = "", e
target = date.fromisoformat(day)
except ValueError as exc:
raise HTTPException(422, "Invalid day") from exc
changed = 0
for entry in db.scalars(select(TimeEntry)).all():
if entry.status == "suggested" and entry.start_at.astimezone(TZ).date() == target:
entry.status = "accepted"
if entry.kind == "zed_inferred":
entry.kind = "manual"
changed += 1
db.commit()
return index_redirect(start or None, end or None)
def format_duration(seconds: int) -> str:
return f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m"
def _next_month(first_day: date) -> date:
if first_day.month == 12:
return date(first_day.year + 1, 1, 1)
return date(first_day.year, first_day.month + 1, 1)
def _month_table_row(day_date: date, entry_days: dict, commit_days: dict) -> tuple[str, int, int, int]:
day_entries = entry_days.get(day_date, [])
day_commits = commit_days.get(day_date, [])
accepted = sum(e.duration_seconds for e in day_entries if e.status == "accepted")
suggested = sum(e.duration_seconds for e in day_entries if e.status == "suggested")
row = f"| {day_date.isoformat()} | {day_date.strftime('%a')} | {format_duration(accepted)} | {format_duration(suggested)} | {len(day_commits)} |"
return row, accepted, suggested, len(day_commits)
def _monthly_markdown_response(first_day: date, last_day: date, entry_days: dict, commit_days: dict) -> Response:
lines = [
f"# Timesheet: {calendar.month_name[first_day.month]} {first_day.year} ({first_day.isoformat()}{last_day.isoformat()})",
"",
f"Generated: {datetime.now(TZ).strftime('%Y-%m-%d %H:%M')} ({APP_TIMEZONE})",
"",
"## Summary by day",
"",
"| Date | Day | Accepted | Suggested (not counted) | Commits |",
"|---|---|---:|---:|---:|",
]
accepted_total = suggested_total = commit_total = days_with_accepted = 0
day_cursor = first_day
while day_cursor <= last_day:
row, accepted, suggested, commits = _month_table_row(day_cursor, entry_days, commit_days)
lines.append(row)
accepted_total += accepted
suggested_total += suggested
commit_total += commits
if accepted:
days_with_accepted += 1
day_cursor += timedelta(days=1)
lines += [
"",
"## Totals",
"",
f"- **Accepted:** {format_duration(accepted_total)}",
f"- **Suggested (not counted):** {format_duration(suggested_total)}",
f"- **Commits:** {commit_total}",
f"- **Days with accepted time:** {days_with_accepted}",
]
for day_date in sorted(entry_days):
day_entries = entry_days[day_date]
day_commits = commit_days.get(day_date, [])
lines.append("")
lines.append(f"### {day_date.strftime('%a %d %b %Y')}")
for heading, statuses in (
("Accepted time", ("accepted",)),
("Suggested time (not counted)", ("suggested",)),
):
selected = [e for e in day_entries if e.status in statuses]
if not selected:
continue
lines += ["", heading, "", "| Start | End | Source | Task | Project | Duration |", "|---|---|---|---|---|---:|"]
for entry in selected:
lines.append(
f"| {entry.start_at.astimezone(TZ).strftime('%H:%M')} "
f"| {entry.end_at.astimezone(TZ).strftime('%H:%M')} "
f"| {entry.source} | {entry.task or ''} | {entry.project_slug or ''} "
f"| {format_duration(entry.duration_seconds)} |"
)
if day_commits:
lines += ["", "Commits", "", "| Time | Project | Author | Commit |", "|---|---|---|---|"]
for commit in day_commits:
sha = (commit.payload.get("sha", "") or "")[:7]
lines.append(
f"| {commit.occurred_at.astimezone(TZ).strftime('%H:%M')} | {commit.project_slug} "
f"| {commit.payload.get('author', '')} | `{sha}` {commit.payload.get('subject', '')} |"
)
return Response(
"\n".join(lines) + "\n",
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="timesheet-{first_day.isoformat()[:7]}.md"'},
)
def _monthly_csv_response(first_day: date, entries: list[TimeEntry]) -> Response:
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["date", "weekday", "start", "end", "source", "status", "task", "project", "duration_minutes"])
for entry in sorted(entries, key=lambda item: item.start_at):
local_start = entry.start_at.astimezone(TZ)
writer.writerow([
local_start.date().isoformat(),
local_start.strftime("%a"),
local_start.strftime("%Y-%m-%dT%H:%M"),
entry.end_at.astimezone(TZ).strftime("%Y-%m-%dT%H:%M"),
entry.source,
entry.status,
entry.task,
entry.project_slug or "",
entry.duration_seconds // 60,
])
return Response(
buffer.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="timesheet-{first_day.isoformat()[:7]}.csv"'},
)
@app.get("/export/monthly")
def export_monthly(request: Request, month: str, format: str = "md", db: Session = Depends(get_db)):
require_web(request)
try:
first_day = date.fromisoformat(f"{month}-01")
except ValueError as exc:
raise HTTPException(422, "month must be formatted YYYY-MM") from exc
last_day = _next_month(first_day) - timedelta(days=1)
start_dt = datetime.combine(first_day, datetime.min.time(), tzinfo=TZ)
end_dt = datetime.combine(_next_month(first_day), datetime.min.time(), tzinfo=TZ)
entries = [
entry for entry in db.scalars(select(TimeEntry))
if entry.status != "rejected" and start_dt <= entry.start_at < end_dt
]
commits = [
commit for commit in db.scalars(select(RawEvent).where(RawEvent.source == "zed_commit"))
if commit.occurred_at is not None and start_dt <= commit.occurred_at < end_dt
]
entry_days = group_entries_by_day(entries)
commit_days = group_commits_by_day(commits)
if format == "csv":
return _monthly_csv_response(first_day, entries)
return _monthly_markdown_response(first_day, last_day, entry_days, commit_days)
@app.post("/entries/{entry_id}/status")
def set_entry_status(entry_id: int, request: Request, status: Annotated[str, Form()], start: Annotated[str, Form()] = "", end: Annotated[str, Form()] = "", db: Session = Depends(get_db)):
require_web(request)
+1
View File
@@ -3,6 +3,7 @@
<body><main class="container"><nav><ul><li><strong>Time Track Server</strong></li></ul><ul><li>{{ timezone }}</li><li><form class="inline" method="post" action="/logout"><button class="secondary outline">Sign out</button></form></li></ul></nav>
<h1>Combined timesheet</h1><form method="get" class="grid"><label>From <input type="date" name="start" value="{{ start }}"></label><label>To <input type="date" name="end" value="{{ end }}"></label><button type="submit">Filter</button></form>
<section class="grid"><article><header>Accepted total</header><strong>{{ format_seconds(accepted) }}</strong></article><article><header>Suggested, not counted</header><strong>{{ format_seconds(suggested) }}</strong></article></section>
<details><summary>Export monthly report</summary><form method="get" action="/export/monthly" class="grid"><label>Month <input type="month" name="month" required></label><label>Format <select name="format"><option value="md">Markdown (.md)</option><option value="csv">CSV (.csv)</option></select></label><button type="submit">Download</button></form></details>
<details><summary>Add manual time</summary><form method="post" action="/entries/manual" class="grid"><label>Start <input name="start_at" type="datetime-local" required></label><label>End <input name="end_at" type="datetime-local" required></label><label>Task <input name="task"></label><label>Project <input name="project_slug"></label><button type="submit">Add accepted time</button></form></details>
<details><summary>Upload mobile-provider call log (PDF)</summary><p>Uploads are extracted and retained for review. A provider-specific parser is added after a redacted sample statement is available.</p><form method="post" action="/call-imports" enctype="multipart/form-data"><input type="file" name="file" accept="application/pdf,.pdf" required><button type="submit">Upload PDF</button></form></details>
<h2>Days</h2>