The design parked in the handoff (multi-tier stats off reading_history; bookorbit-style in data, not looks) is in. The real prerequisite work was data: reading_history existed but NOTHING wrote to it — the whole stats feature was sitting on an empty table. - CAPTURE (no client changes, no repo-boundary crossings): every successful progress write through SaveProgress (KOReader + app + web + Kobo + offline queue — the one chokepoint) now records one reading_history session row: span = the interval since the PREVIOUS write instant, pages = positive page delta when both sides know the page index, marker rows for first contact. Spans over 2h are kept as zero-second markers (overnight suspend→resume, offline flushes must not inflate totals) — the honest 'sync cadence, not a stopwatch' caveat stays. device_id only when a REAL device row matches (the web/app progress path historically stuffs the USER id into DeviceID — probe instead of trusting it; FK violation would silently drop every capture). Source preserved in device_metadata. - API: /api/stats/summary?range=7|30|90|year (totals, zero-filled daily arrays, streaks, most-active day/weekday, top books, library breakdown — one call), /api/stats/this-week (widget: rolling 7d vs previous + streak), /api/stats/books/:id (lifetime + recent sessions). All bucketed in the user's timezone; finished ≡ any push ≥0.995; visible-library lens enforced at source. Legacy /api/analytics/* kept untouched. Range windows anchor end-of-day to the START of today (blasting the clock time into tomorrow resolved 'today' a calendar day late — found by the streak test). - /stats page (sidebar Stats between Progress and Devices, previously /analytics was an orphan): range selector, five stat cards, two Chart.js v4 charts (daily minutes bar, pages/day line — chunked via dynamic import so main.js stays untouched), per-library breakdown, most-read list, empty-state explainer. /analytics now 301s to /stats. The orphaned analytics.templ + analytics.ts removed (their containers and the page JSON never matched; the page was astub). - Dashboard 'This Week' widget (server-rendered, suppressed on service failure) and the book detail 'Your Reading Stats' card (lifetime totals of the viewer's own sessions) complete the tiers: per-user default, per-library breakdown inline, per-book on the page, per-system/admin aggregate parked as before. Tests: statistics_test.go (capture semantics at the chokepoint, tz/day buckets, zero-fill, streak math, finished detection, range validation, auth gate, widget, per-book lifetime). Full suite PASS.
94 lines
4.9 KiB
Templ
94 lines
4.9 KiB
Templ
package templates
|
||
|
||
templ Stats(user User) {
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8"/>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Reading Stats - Bookhoard</title>
|
||
<script src="/static/htmx.min.js"></script>
|
||
<link href="/static/style.css" rel="stylesheet"/>
|
||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||
</head>
|
||
<body x-data="stats" x-init="initStatsPage()" class="theme-{ user.Theme }">
|
||
@Header(user, "/stats")
|
||
<div class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
||
<div class="mb-8">
|
||
<div class="flex items-center gap-3 mb-1">
|
||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||
@Icon("chart", "h-5 w-5")
|
||
</span>
|
||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Reading Stats</h1>
|
||
</div>
|
||
<p class="text-sm" style="color: var(--text-secondary)">Days are bucketed in your timezone ({ user.Timezone }) · totals derive from your devices' sync pushes, not a stopwatch.</p>
|
||
</div>
|
||
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||
<div class="flex gap-2" id="stats-range-selector">
|
||
<button data-range="7" @click="selectRange('7')" class="stats-range-btn btn btn-secondary text-sm">Last 7 days</button>
|
||
<button data-range="30" @click="selectRange('30')" class="stats-range-btn btn btn-secondary text-sm">30 days</button>
|
||
<button data-range="90" @click="selectRange('90')" class="stats-range-btn btn btn-secondary text-sm">90 days</button>
|
||
<button data-range="year" @click="selectRange('year')" class="stats-range-btn btn btn-secondary text-sm">This year</button>
|
||
</div>
|
||
<p class="text-xs" style="color: var(--text-secondary)" id="stats-window-label"></p>
|
||
</div>
|
||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||
<div class="stat-card">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Reading Time</h3>
|
||
<p id="stats-total-minutes" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Pages</h3>
|
||
<p id="stats-total-pages" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Streaks</h3>
|
||
<p id="stats-streaks" class="text-lg font-bold leading-tight mt-1" style="color: var(--text-primary)">–</p>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Books Finished</h3>
|
||
<p id="stats-books-finished" class="text-3xl font-bold" style="color: var(--accent)">–</p>
|
||
</div>
|
||
<div class="stat-card">
|
||
<h3 class="text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Most Active</h3>
|
||
<p id="stats-most-active" class="text-sm font-bold leading-snug mt-2" style="color: var(--text-primary)">–</p>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||
<div class="card p-6">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Daily Reading Minutes</h3>
|
||
<div class="h-64">
|
||
<canvas id="stats-daily-minutes-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="card p-6">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Pages per Day</h3>
|
||
<div class="h-64">
|
||
<canvas id="stats-daily-pages-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||
<div class="card p-6">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">By Library</h3>
|
||
<div id="stats-library-breakdown" class="space-y-3">
|
||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||
</div>
|
||
</div>
|
||
<div class="card p-6">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Most Read</h3>
|
||
<div id="stats-top-books" class="space-y-3">
|
||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div id="stats-empty" class="card p-8 text-center hidden">
|
||
<p class="text-sm mb-2" style="color: var(--text-primary)">No reading sessions recorded in this range yet.</p>
|
||
<p class="text-xs" style="color: var(--text-secondary)">Sessions are captured every time a device syncs reading progress (KOReader, the app, the web reader).</p>
|
||
</div>
|
||
</div>
|
||
@ErrorToast("")
|
||
</body>
|
||
</html>
|
||
}
|