Add monthly report export as Markdown and CSV
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
+140
-3
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user