diff --git a/README.md b/README.md index 16ac79c..88fcc2d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int - 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. - 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. Suggestions overlapping accepted time cannot be accepted — "Accept day" skips them and they stay as evidence until rejected, so inferred time is never double-counted. - 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. ## Deploy diff --git a/app/main.py b/app/main.py index a41c3f6..56024d8 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,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, group_commits_by_day, group_entries_by_day, overlaps_accepted, parse_timestamp +from .services import TZ, derive_zed_suggestions, group_commits_by_day, group_entries_by_day, overlaps_accepted, overlaps_entries, parse_timestamp APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna") ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "") @@ -248,11 +248,20 @@ def accept_day(day: str, request: Request, start: Annotated[str, Form()] = "", e target = date.fromisoformat(day) except ValueError as exc: raise HTTPException(422, "Invalid day") from exc - 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" + entries = list(db.scalars(select(TimeEntry))) + accepted_rows = [entry for entry in entries if entry.status == "accepted"] + for entry in entries: + if entry.status != "suggested" or entry.start_at.astimezone(TZ).date() != target: + continue + # Inferred time overlapping accepted time is evidence only: accepting + # it would double-count. It stays suggested (shown as overlapping) and + # can be rejected individually. + if overlaps_entries(entry, accepted_rows): + continue + entry.status = "accepted" + if entry.kind == "zed_inferred": + entry.kind = "manual" + accepted_rows.append(entry) db.commit() return index_redirect(start or None, end or None) @@ -401,6 +410,8 @@ def set_entry_status(entry_id: int, request: Request, status: Annotated[str, For entry = db.get(TimeEntry, entry_id) if not entry: raise HTTPException(404, "Entry not found") + if status == "accepted" and overlaps_accepted(db, entry): + raise HTTPException(422, "Entry overlaps accepted time and cannot be accepted") entry.status = status # An accepted inference becomes a durable user-approved manual entry. if status == "accepted" and entry.kind == "zed_inferred": diff --git a/app/services.py b/app/services.py index b94c6ac..69e0059 100644 --- a/app/services.py +++ b/app/services.py @@ -85,6 +85,13 @@ def overlaps_accepted(db: Session, entry: TimeEntry) -> bool: return bool(matching) +def overlaps_entries(entry: TimeEntry, accepted_entries: list[TimeEntry]) -> bool: + return any( + other.id != entry.id and other.start_at < entry.end_at and other.end_at > entry.start_at + for other in accepted_entries + ) + + def daily_summary(entries: list[TimeEntry]) -> dict: totals: dict[str, int] = defaultdict(int) suggestions = 0 diff --git a/app/templates/index.html b/app/templates/index.html index 09144a9..a440175 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -18,7 +18,7 @@
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
{{ 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 (not acceptable){% endif %}{% endif %}
{{ entry.task or '—' }}{% if entry.project_slug %}
{{ entry.project_slug }}{% endif %}
{{ format_seconds(entry.duration_seconds) }}{{ entry.status }}{% if entry.status == 'suggested' and not overlaps_accepted(entry) %}
{% endif %}{% if entry.status != 'rejected' %}
{% endif %}
Edit