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
{{ format_seconds(accepted) }}{{ 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
| Start | End | Source | Task / project | Duration | Status | Actions |
-{% for entry in entries %}| {{ 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 |
-{% else %}| No entries yet. Sync a tracker or add manual time. |
{% endfor %}
+Days
+{% for day in days %}
+
+
+ {% if day.entries %}
+ Show {{ day.entries|length }} entr{{ 'y' if day.entries|length == 1 else 'ies' }}
+
+
+ {% else %}No tracked entries this day.
{% endif %}
+ {% if day.commits %}
+ Git commits ({{ day.commits|length }})
+ | Time | Project | Author | Commit |
+ {% for commit in day.commits %}
+ | {{ 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', '') }} |
+ {% endfor %}
+
+
+ {% endif %}
+
+{% else %}
+No entries yet. Sync a tracker or add manual time.
+{% endfor %}
+