Never count overlapping inferred time as accepted

This commit is contained in:
2026-09-14 15:24:41 +02:00
parent b09b536b49
commit c6040a8cb2
4 changed files with 26 additions and 8 deletions
+1 -1
View File
@@ -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
+14 -3
View File
@@ -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:
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":
+7
View File
@@ -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
+1 -1
View File
@@ -18,7 +18,7 @@
<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>
<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 (not acceptable){% 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' and not overlaps_accepted(entry) %}<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>