From b09b536b490ed2466f7621eac4ac0df22fa6c084 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 14 Sep 2026 15:07:49 +0200 Subject: [PATCH] Add monthly report export as Markdown and CSV --- README.md | 1 + app/main.py | 143 ++++++++++++++++++++++++++++++++++++++- app/templates/index.html | 1 + 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 584b4c2..16ac79c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int - Docker Compose deployment: FastAPI app and private PostgreSQL database. - Password-protected single-user web UI: day-grouped timesheets with per-day totals, "Accept day" for suggested time, expandable day views showing every entry (accept/reject individually), and the synced Zed git-commit history per day. - Token-authenticated, idempotent ingestion endpoints for both local trackers. +- Monthly report export as Markdown or CSV: `GET /export/monthly?month=YYYY-MM&format=md|csv` (also in the UI). Full-calendar-month day summaries, totals, per-day detail with accepted and suggested time kept separate, and the month's git commits. Rejected entries are excluded. - Explicit `time_track` intervals are accepted time; Zed heartbeat blocks are **suggested only**, never included in accepted totals until approved. - PDF call-log upload with durable source-file storage and text extraction. A provider-specific parser is intentionally deferred until a redacted mobile-provider sample statement establishes the layout. diff --git a/app/main.py b/app/main.py index 2508aad..a41c3f6 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/templates/index.html b/app/templates/index.html index 989c523..09144a9 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -3,6 +3,7 @@

Combined timesheet

Accepted total
{{ format_seconds(accepted) }}
Suggested, not counted
{{ format_seconds(suggested) }}
+
Export monthly report
Add manual time
Upload mobile-provider call log (PDF)

Uploads are extracted and retained for review. A provider-specific parser is added after a redacted sample statement is available.

Days