Never count overlapping inferred time as accepted
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Deploy
|
||||||
|
|||||||
+17
-6
@@ -25,7 +25,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from .database import Base, engine, get_db
|
from .database import Base, engine, get_db
|
||||||
from .models import CallImport, RawEvent, TimeEntry
|
from .models import CallImport, RawEvent, TimeEntry
|
||||||
from .schemas import TimeTrackSync, ZedSync
|
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")
|
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna")
|
||||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
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)
|
target = date.fromisoformat(day)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(422, "Invalid day") from exc
|
raise HTTPException(422, "Invalid day") from exc
|
||||||
for entry in db.scalars(select(TimeEntry)).all():
|
entries = list(db.scalars(select(TimeEntry)))
|
||||||
if entry.status == "suggested" and entry.start_at.astimezone(TZ).date() == target:
|
accepted_rows = [entry for entry in entries if entry.status == "accepted"]
|
||||||
entry.status = "accepted"
|
for entry in entries:
|
||||||
if entry.kind == "zed_inferred":
|
if entry.status != "suggested" or entry.start_at.astimezone(TZ).date() != target:
|
||||||
entry.kind = "manual"
|
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()
|
db.commit()
|
||||||
return index_redirect(start or None, end or None)
|
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)
|
entry = db.get(TimeEntry, entry_id)
|
||||||
if not entry:
|
if not entry:
|
||||||
raise HTTPException(404, "Entry not found")
|
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
|
entry.status = status
|
||||||
# An accepted inference becomes a durable user-approved manual entry.
|
# An accepted inference becomes a durable user-approved manual entry.
|
||||||
if status == "accepted" and entry.kind == "zed_inferred":
|
if status == "accepted" and entry.kind == "zed_inferred":
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ def overlaps_accepted(db: Session, entry: TimeEntry) -> bool:
|
|||||||
return bool(matching)
|
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:
|
def daily_summary(entries: list[TimeEntry]) -> dict:
|
||||||
totals: dict[str, int] = defaultdict(int)
|
totals: dict[str, int] = defaultdict(int)
|
||||||
suggestions = 0
|
suggestions = 0
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<details><summary>Show {{ day.entries|length }} entr{{ 'y' if day.entries|length == 1 else 'ies' }}</summary>
|
<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>
|
<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 %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody></table></figure>
|
</tbody></table></figure>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
Reference in New Issue
Block a user