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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import urlencode
|
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)
|
target = date.fromisoformat(day)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, "Invalid day") from exc
|
raise HTTPException(422, "Invalid day") from exc
|
||||||
changed = 0
|
|
||||||
for entry in db.scalars(select(TimeEntry)).all():
|
for entry in db.scalars(select(TimeEntry)).all():
|
||||||
if entry.status == "suggested" and entry.start_at.astimezone(TZ).date() == target:
|
if entry.status == "suggested" and entry.start_at.astimezone(TZ).date() == target:
|
||||||
entry.status = "accepted"
|
entry.status = "accepted"
|
||||||
if entry.kind == "zed_inferred":
|
if entry.kind == "zed_inferred":
|
||||||
entry.kind = "manual"
|
entry.kind = "manual"
|
||||||
changed += 1
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return index_redirect(start or None, end or None)
|
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")
|
@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)):
|
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)
|
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>
|
<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>
|
<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>
|
<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>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>
|
<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>
|
<h2>Days</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user