Files
bookhoard/templates/stats.templ
T
John O'Keefe 7a604c2fec feat(stats): per-device reading breakdown + device attribution on progress syncs
- Attribution: registered clients may now send their device token in
  X-Device-Token on PUT /api/media-items/:id/progress (the user JWT
  stays primary). The token must map to a device OWNED BY the caller —
  foreign/invalid/absent tokens soft-fail to plain web attribution so a
  stats decoration can never break a sync. Attributed pushes capture
  device_id (FK-safe), device_metadata.source = the registered device
  name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show
  that device. Side effect worth having: app-vs-web pushes used to be
  LWW-invisible (both sources read 'web'); they now surface as
  legitimate cross-source progress conflicts when they diverge.
- /stats gains a By Device panel alongside By Library (grid 3-wide):
  reading minutes + sessions + books per registered device, plus one
  Web / unsynced bucket for device-less pushes so the panel always
  reconciles with the headline totals. Range-aware, family-lens
  scoped; renderBars shared between panels in stats.ts.
- The device identity IS the registered platform: devices rows already
  carry the name/type the client registered (app sends Build.MODEL +
  'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new
  identity plumbing on the server.

Tests: capture attributed to the device (row device_id + source
metadata + last_sync_source), foreign/nonexistent tokens soft-fail,
device buckets reconcile (80 attributed + 75 unsynced = 155 total).
Full suite PASS.
2026-10-14 13:04:34 -04:00

101 lines
5.4 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-3 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)">By Device</h3>
<p class="text-xs mb-3" style="color: var(--text-secondary)">Where the sync pushes came from — registered device names, or "Web / unsynced" when the push carried no device credential.</p>
<div id="stats-device-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>
}