Add day-grouped timesheet with day accept and commit history
This commit is contained in:
@@ -5,7 +5,7 @@ A self-hosted, single-user combined timesheet server for `time_track` manual int
|
|||||||
## What is implemented
|
## What is implemented
|
||||||
|
|
||||||
- Docker Compose deployment: FastAPI app and private PostgreSQL database.
|
- 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.
|
- 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.
|
- 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.
|
- 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
@@ -3,9 +3,10 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from urllib.parse import urlencode
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
@@ -21,7 +22,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, 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")
|
APP_TIMEZONE = os.environ.get("APP_TIMEZONE", "Europe/Vienna")
|
||||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request, start: str | None = None, end: str | None = None, db: Session = Depends(get_db)):
|
def index(request: Request, start: str | None = None, end: str | None = None, db: Session = Depends(get_db)):
|
||||||
require_web(request)
|
require_web(request)
|
||||||
query = select(TimeEntry).order_by(TimeEntry.start_at.desc())
|
start_date = datetime.fromisoformat(start).replace(tzinfo=TZ) if start else None
|
||||||
entries = db.scalars(query).all()
|
end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59) if end else None
|
||||||
if start:
|
|
||||||
start_date = datetime.fromisoformat(start).replace(tzinfo=TZ)
|
entries = list(db.scalars(select(TimeEntry)))
|
||||||
entries = [e for e in entries if e.start_at >= start_date]
|
entries = [
|
||||||
if end:
|
entry for entry in entries
|
||||||
end_date = datetime.fromisoformat(end).replace(tzinfo=TZ).replace(hour=23, minute=59, second=59)
|
if (start_date is None or entry.start_at >= start_date)
|
||||||
entries = [e for e in entries if e.start_at <= end_date]
|
and (end_date is None or entry.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")
|
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", {
|
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,
|
"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",
|
"format_seconds": lambda seconds: f"{seconds // 3600}h {(seconds % 3600) // 60:02d}m",
|
||||||
"overlaps_accepted": lambda entry: overlaps_accepted(db, entry),
|
"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")
|
@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)):
|
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)
|
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)
|
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")
|
@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)
|
require_web(request)
|
||||||
if status not in {"accepted", "rejected", "suggested"}:
|
if status not in {"accepted", "rejected", "suggested"}:
|
||||||
raise HTTPException(422, "Invalid status")
|
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":
|
if status == "accepted" and entry.kind == "zed_inferred":
|
||||||
entry.kind = "manual"
|
entry.kind = "manual"
|
||||||
db.commit()
|
db.commit()
|
||||||
return RedirectResponse("/", status_code=303)
|
return index_redirect(start or None, end or None)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/call-imports", response_class=HTMLResponse)
|
@app.post("/call-imports", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ def derive_zed_suggestions(db: Session, device_id: str, project_slug: str, idle_
|
|||||||
TimeEntry.kind == "zed_inferred",
|
TimeEntry.kind == "zed_inferred",
|
||||||
TimeEntry.status == "suggested",
|
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]] = []
|
blocks: list[tuple[datetime, datetime, int]] = []
|
||||||
start = previous = None
|
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:
|
for start_at, end_at, duration in blocks:
|
||||||
entry_id = stable_id(device_id, project_slug, start_at.isoformat(), end_at.isoformat())
|
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(
|
db.add(TimeEntry(
|
||||||
source="zed", device_id=device_id, external_id=entry_id, kind="zed_inferred",
|
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,
|
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":
|
elif entry.status == "suggested":
|
||||||
suggestions += entry.duration_seconds
|
suggestions += entry.duration_seconds
|
||||||
return {"by_day": dict(totals), "suggested_seconds": suggestions}
|
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)
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
<!doctype html>
|
<!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>
|
<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>
|
<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>
|
<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>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>
|
<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>
|
<h2>Days</h2>
|
||||||
{% 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>
|
{% for day in days %}
|
||||||
{% else %}<tr><td colspan="7">No entries yet. Sync a tracker or add manual time.</td></tr>{% endfor %}</tbody></table></figure></main></body></html>
|
<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>
|
||||||
Reference in New Issue
Block a user