Add day-grouped timesheet with day accept and commit history

This commit is contained in:
2026-09-14 15:04:55 +02:00
parent dcd2e97a1d
commit 75e47d7f40
4 changed files with 124 additions and 20 deletions
+1 -1
View File
@@ -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.
+62 -15
View File
@@ -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)
+29
View File
@@ -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)
+32 -4
View File
@@ -1,10 +1,38 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Timesheets · Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"><style>small.suggested{color:#9a6700}.rejected{opacity:.5}td{vertical-align:top}form.inline{display:inline}</style></head>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Timesheets · Time Track Server</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"><style>small.suggested{color:#9a6700}.rejected{opacity:.5}td{vertical-align:top}form.inline{display:inline;margin:0}.day{margin-bottom:1.5rem;padding-top:.75rem;border-top:2px solid var(--pico-muted-border-color)}.day-header{display:flex;gap:1rem;align-items:baseline;flex-wrap:wrap;margin-bottom:.25rem}.day-header h2{margin:0;font-size:1.1rem}.day-total{color:var(--pico-muted-color)}details{margin-bottom:.5rem}</style></head>
<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>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>Entries</h2><figure><table><thead><tr><th>Start</th><th>End</th><th>Source</th><th>Task / project</th><th>Duration</th><th>Status</th><th>Actions</th></tr></thead><tbody>
{% for entry in entries %}<tr class="{{ entry.status }}"><td>{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%d %H:%M') }}</td><td>{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ entry.source }}{% if entry.kind == 'zed_inferred' %}<br><small class="suggested">inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}</small>{% endif %}</td><td>{{ entry.task or '—' }}{% if entry.project_slug %}<br><small>{{ entry.project_slug }}</small>{% endif %}</td><td>{{ format_seconds(entry.duration_seconds) }}</td><td>{{ entry.status }}</td><td>{% if entry.status == 'suggested' %}<form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="accepted"><button>Accept</button></form><form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="rejected"><button class="secondary">Reject</button></form>{% endif %}<details><summary>Edit</summary><form method="post" action="/entries/{{ entry.id }}/edit"><label>Start <input name="start_at" type="datetime-local" value="{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>End <input name="end_at" type="datetime-local" value="{{ entry.end_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>Task <input name="task" value="{{ entry.task }}"></label><label>Project <input name="project_slug" value="{{ entry.project_slug or '' }}"></label><label>Notes <textarea name="notes">{{ entry.notes }}</textarea></label><button type="submit">Save</button></form></details></td></tr>
{% else %}<tr><td colspan="7">No entries yet. Sync a tracker or add manual time.</td></tr>{% endfor %}</tbody></table></figure></main></body></html>
<h2>Days</h2>
{% for day in days %}
<section class="day">
<header class="day-header">
<h2>{{ day.date.strftime('%a %d %b %Y') }}</h2>
<span class="day-total">accepted <strong>{{ format_seconds(day.accepted) }}</strong> · suggested <span class="suggested">{{ format_seconds(day.suggested) }}</span>{% if day.commits %} · {{ day.commits|length }} commit{{ 's' if day.commits|length != 1 }}{% endif %}</span>
{% if day.suggested %}<form class="inline" method="post" action="/days/{{ day.date.isoformat() }}/accept"><input type="hidden" name="start" value="{{ start }}"><input type="hidden" name="end" value="{{ end }}"><button>Accept day</button></form>{% endif %}
</header>
{% if day.entries %}
<details><summary>Show {{ day.entries|length }} entr{{ 'y' if day.entries|length == 1 else 'ies' }}</summary>
<figure><table><thead><tr><th>Start</th><th>End</th><th>Source</th><th>Task / project</th><th>Duration</th><th>Status</th><th>Actions</th></tr></thead><tbody>
{% for entry in day.entries %}
<tr class="{{ entry.status }}"><td>{{ entry.start_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ entry.end_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ entry.source }}{% if entry.kind == 'zed_inferred' %}<br><small class="suggested">inferred{% if overlaps_accepted(entry) %}; overlaps accepted time{% endif %}</small>{% endif %}</td><td>{{ entry.task or '—' }}{% if entry.project_slug %}<br><small>{{ entry.project_slug }}</small>{% endif %}</td><td>{{ format_seconds(entry.duration_seconds) }}</td><td>{{ entry.status }}</td><td>{% if entry.status == 'suggested' %}<form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="accepted"><input type="hidden" name="start" value="{{ start }}"><input type="hidden" name="end" value="{{ end }}"><button>Accept</button></form>{% endif %}{% if entry.status != 'rejected' %}<form class="inline" method="post" action="/entries/{{ entry.id }}/status"><input type="hidden" name="status" value="rejected"><input type="hidden" name="start" value="{{ start }}"><input type="hidden" name="end" value="{{ end }}"><button class="secondary">Reject</button></form>{% endif %}<details><summary>Edit</summary><form method="post" action="/entries/{{ entry.id }}/edit"><label>Start <input name="start_at" type="datetime-local" value="{{ entry.start_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>End <input name="end_at" type="datetime-local" value="{{ entry.end_at.astimezone(display_tz).strftime('%Y-%m-%dT%H:%M') }}" required></label><label>Task <input name="task" value="{{ entry.task }}"></label><label>Project <input name="project_slug" value="{{ entry.project_slug or '' }}"></label><label>Notes <textarea name="notes">{{ entry.notes }}</textarea></label><button type="submit">Save</button></form></details></td></tr>
{% endfor %}
</tbody></table></figure>
</details>
{% else %}<p><small>No tracked entries this day.</small></p>{% endif %}
{% if day.commits %}
<details><summary>Git commits ({{ day.commits|length }})</summary>
<figure><table><thead><tr><th>Time</th><th>Project</th><th>Author</th><th>Commit</th></tr></thead><tbody>
{% for commit in day.commits %}
<tr><td>{{ commit.occurred_at.astimezone(display_tz).strftime('%H:%M') }}</td><td>{{ commit.project_slug }}</td><td>{{ commit.payload.get('author', '') }}</td><td><code>{{ (commit.payload.get('sha', '') or '')[:7] }}</code> {{ commit.payload.get('subject', '') }}</td></tr>
{% endfor %}
</tbody></table></figure>
</details>
{% endif %}
</section>
{% else %}
<p>No entries yet. Sync a tracker or add manual time.</p>
{% endfor %}
</main></body></html>