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
+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)