From 75e47d7f40080cde0ee3b31b0567a7418360408a Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 14 Sep 2026 15:04:55 +0200 Subject: [PATCH] Add day-grouped timesheet with day accept and commit history --- README.md | 2 +- app/main.py | 77 ++++++++++++++++++++++++++++++++-------- app/services.py | 29 +++++++++++++++ app/templates/index.html | 36 ++++++++++++++++--- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ad87a5e..584b4c2 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int ## What is implemented - Docker Compose deployment: FastAPI app and private PostgreSQL database. -- Password-protected single-user web UI for browsing combined entries, filtering dates, adding manual time, and accepting/rejecting Zed suggestions. +- 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. - 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 2953fc9..2508aad 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,10 @@ import os import secrets import shutil import time -from datetime import datetime +from datetime import date, datetime from pathlib import Path from typing import Annotated +from urllib.parse import urlencode from uuid import uuid4 import pdfplumber @@ -21,7 +22,7 @@ from starlette.middleware.sessions import SessionMiddleware from .database import Base, engine, get_db from .models import CallImport, RawEvent, TimeEntry from .schemas import TimeTrackSync, ZedSync -from .services import TZ, derive_zed_suggestions, overlaps_accepted, parse_timestamp +from .services import TZ, derive_zed_suggestions, group_commits_by_day, group_entries_by_day, overlaps_accepted, parse_timestamp APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") @@ -157,24 +158,52 @@ def sync_zed_commits(payload: ZedSync, db: Session = Depends(get_db)) -> dict: @app.get("/", response_class=HTMLResponse) def index(request: Request, start: str | None = None, end: str | None = None, db: Session = Depends(get_db)): require_web(request) - query = select(TimeEntry).order_by(TimeEntry.start_at.desc()) - entries = db.scalars(query).all() - if start: - start_date = datetime.fromisoformat(start).replace(tzinfo=TZ) - entries = [e for e in entries if e.start_at >= start_date] - if end: - end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59) - entries = [e for e in entries if e.start_at <= end_date] - accepted = sum(e.duration_seconds for e in entries if e.status == "accepted") - suggested = sum(e.duration_seconds for e in entries if e.status == "suggested") + start_date = datetime.fromisoformat(start).replace(tzinfo=TZ) if start else None + end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59) if end else None + + entries = list(db.scalars(select(TimeEntry))) + entries = [ + entry for entry in entries + if (start_date is None or entry.start_at >= start_date) + and (end_date is None or entry.start_at <= end_date) + ] + commits = list(db.scalars(select(RawEvent).where(RawEvent.source == "zed_commit").order_by(RawEvent.occurred_at))) + commits = [ + commit for commit in commits + if commit.occurred_at is not None + and (start_date is None or commit.occurred_at >= start_date) + and (end_date is None or commit.occurred_at <= end_date) + ] + + entry_days = group_entries_by_day(entries) + commit_days = group_commits_by_day(commits) + days = [] + for day_date in sorted(set(entry_days) | set(commit_days), reverse=True): + day_entries = entry_days.get(day_date, []) + days.append({ + "date": day_date, + "entries": day_entries, + "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"), + "commits": commit_days.get(day_date, []), + }) + return templates.TemplateResponse(request, "index.html", { - "entries": entries, "accepted": accepted, "suggested": suggested, + "days": days, + "accepted": sum(day["accepted"] for day in days), + "suggested": sum(day["suggested"] for day in days), "start": start or "", "end": end or "", "timezone": APP_TIMEZONE, "display_tz": TZ, "format_seconds": lambda seconds: f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m", "overlaps_accepted": lambda entry: overlaps_accepted(db, entry), }) +def index_redirect(start: str | None, end: str | None) -> RedirectResponse: + params = {name: value for name, value in (("start", start), ("end", end)) if value} + suffix = "?" + urlencode(params) if params else "" + return RedirectResponse("/" + suffix, status_code=303) + + @app.post("/entries/manual") def create_manual(request: Request, start_at: Annotated[str, Form()], end_at: Annotated[str, Form()], task: Annotated[str, Form()] = "", project_slug: Annotated[str, Form()] = "", db: Session = Depends(get_db)): require_web(request) @@ -209,8 +238,26 @@ def edit_entry(entry_id: int, request: Request, start_at: Annotated[str, Form()] return RedirectResponse("/", status_code=303) +@app.post("/days/{day}/accept") +def accept_day(day: str, request: Request, start: Annotated[str, Form()] = "", end: Annotated[str, Form()] = "", db: Session = Depends(get_db)): + require_web(request) + try: + 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) + + @app.post("/entries/{entry_id}/status") -def set_entry_status(entry_id: int, request: Request, status: 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) if status not in {"accepted", "rejected", "suggested"}: raise HTTPException(422, "Invalid status") @@ -222,7 +269,7 @@ def set_entry_status(entry_id: int, request: Request, status: Annotated[str, For if status == "accepted" and entry.kind == "zed_inferred": entry.kind = "manual" db.commit() - return RedirectResponse("/", status_code=303) + return index_redirect(start or None, end or None) @app.post("/call-imports", response_class=HTMLResponse) diff --git a/app/services.py b/app/services.py index 973238f..b94c6ac 100644 --- a/app/services.py +++ b/app/services.py @@ -42,6 +42,11 @@ def derive_zed_suggestions(db: Session, device_id: str, project_slug: str, idle_ TimeEntry.kind == "zed_inferred", TimeEntry.status == "suggested", )) + existing_ids = set(db.scalars(select(TimeEntry.external_id).where( + TimeEntry.source == "zed", + TimeEntry.device_id == device_id, + TimeEntry.project_slug == project_slug, + ))) blocks: list[tuple[datetime, datetime, int]] = [] start = previous = None @@ -59,6 +64,11 @@ def derive_zed_suggestions(db: Session, device_id: str, project_slug: str, idle_ for start_at, end_at, duration in blocks: entry_id = stable_id(device_id, project_slug, start_at.isoformat(), end_at.isoformat()) + # Blocks whose stable ID already exists (accepted, rejected, or still + # suggested) must not be re-added: the unique constraint would reject + # the insert, and user decisions about a block must stick. + if entry_id in existing_ids: + continue db.add(TimeEntry( source="zed", device_id=device_id, external_id=entry_id, kind="zed_inferred", status="suggested", start_at=start_at, end_at=end_at, duration_seconds=duration, @@ -85,3 +95,22 @@ def daily_summary(entries: list[TimeEntry]) -> dict: elif entry.status == "suggested": suggestions += entry.duration_seconds return {"by_day": dict(totals), "suggested_seconds": suggestions} + + +def group_entries_by_day(entries: list[TimeEntry]) -> dict: + days: dict = defaultdict(list) + for entry in entries: + days[entry.start_at.astimezone(TZ).date()].append(entry) + for day_entries in days.values(): + day_entries.sort(key=lambda item: item.start_at) + return dict(days) + + +def group_commits_by_day(commits: list[RawEvent]) -> dict: + days: dict = defaultdict(list) + for commit in commits: + if commit.occurred_at is not None: + days[commit.occurred_at.astimezone(TZ).date()].append(commit) + for day_commits in days.values(): + day_commits.sort(key=lambda item: item.occurred_at) + return dict(days) diff --git a/app/templates/index.html b/app/templates/index.html index 56fb8a8..989c523 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,10 +1,38 @@ -Timesheets · Time Track Server +Timesheets · Time Track Server

Combined timesheet

Accepted total
{{ format_seconds(accepted) }}
Suggested, not counted
{{ format_seconds(suggested) }}
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.

-

Entries

-{% for entry in entries %} -{% else %}{% endfor %}
StartEndSourceTask / projectDurationStatusActions
{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%d %H:%M') }}{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}{{ entry.source }}{% if entry.kind == 'zed_inferred' %}
inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}{% endif %}
{{ entry.task or '—' }}{% if entry.project_slug %}
{{ entry.project_slug }}{% endif %}
{{ format_seconds(entry.duration_seconds) }}{{ entry.status }}{% if entry.status == 'suggested' %}
{% endif %}
Edit
No entries yet. Sync a tracker or add manual time.
+

Days

+{% for day in days %} +
+
+

{{ day.date.strftime('%a %d %b %Y') }}

+ accepted {{ format_seconds(day.accepted) }} · suggested {{ format_seconds(day.suggested) }}{% if day.commits %} · {{ day.commits|length }} commit{{ 's' if day.commits|length != 1 }}{% endif %} + {% if day.suggested %}
{% endif %} +
+ {% if day.entries %} +
Show {{ day.entries|length }} entr{{ 'y' if day.entries|length == 1 else 'ies' }} +
+ {% for entry in day.entries %} + + {% endfor %} +
StartEndSourceTask / projectDurationStatusActions
{{ entry.start_at.astimezone(display_tz).strftime('%H:%M') }}{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}{{ entry.source }}{% if entry.kind == 'zed_inferred' %}
inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}{% endif %}
{{ entry.task or '—' }}{% if entry.project_slug %}
{{ entry.project_slug }}{% endif %}
{{ format_seconds(entry.duration_seconds) }}{{ entry.status }}{% if entry.status == 'suggested' %}
{% endif %}{% if entry.status != 'rejected' %}
{% endif %}
Edit
+
+ {% else %}

No tracked entries this day.

{% endif %} + {% if day.commits %} +
Git commits ({{ day.commits|length }}) +
+ {% for commit in day.commits %} + + {% endfor %} +
TimeProjectAuthorCommit
{{ commit.occurred_at.astimezone(display_tz).strftime('%H:%M') }}{{ commit.project_slug }}{{ commit.payload.get('author', '') }}{{ (commit.payload.get('sha', '') or '')[:7] }} {{ commit.payload.get('subject', '') }}
+
+ {% endif %} +
+{% else %} +

No entries yet. Sync a tracker or add manual time.

+{% endfor %} + \ No newline at end of file