The "top search bar doesn't work" report had TWO stacked causes, found by reproducing with real keypress-by-keypress typing instead of Playwright's fill() (which fires a single input event and masked both): 1. htmx.min.js was included PER PAGE. The header's live search is htmx-driven on every page, but five pages rendering @Header — series, admin, admin hash-conflicts, admin processing-issues, browse-detail — never loaded it: the input's hx-* attributes were inert (htmx-internal-data absent), so typing produced no request at all. The include now lives in the header partial itself (classic script, before the module bundle); the sixteen per-page copies on @Header pages are removed (double-processing guard). Non-header pages (login/register/setup/reader/index) keep their own. 2. htmx's delay: modifier on a PROGRAMMATICALLY dispatched custom event (htmx.trigger) loses the request entirely under rapid successive triggers — every re-fire canceled the pending timer and the last never issued, so fast typing (the normal kind) never opened the dropdown while one-shot fill() worked. The debounce moved into the Alpine component (client-local state, where it belongs); htmx now executes the bare `search` event immediately. Also made bundle staleness structurally impossible: each bundle's script stamp is a content hash computed at startup (templates/ bundles.go), so a rebuild that changes bundle bytes changes its cache key with no manual ?v=N bump — the manual stamp was how the first dropdown fix stayed invisible for a deploy. The reader bundle (never stamped at all) is covered too. HTML pages additionally send Cache-Control: no-cache (they are dynamic), so a stale page can no longer reference a stale stamp; /api, /static and /uploads keep their native caching, pinned by a middleware test.
177 lines
10 KiB
Templ
177 lines
10 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>
|
||
<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 class="flex gap-2 mt-4" id="stats-tab-row">
|
||
<button data-tab="reading" @click="selectTab('reading')" class="stats-tab-btn btn btn-secondary text-sm">Reading</button>
|
||
<button data-tab="library" @click="selectTab('library')" class="stats-tab-btn btn btn-secondary text-sm">Library</button>
|
||
</div>
|
||
</div>
|
||
<!-- READING tab — behavior stats from sync pushes (all libraries) -->
|
||
<div id="stats-reading-section">
|
||
<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)" id="stats-pages-card-label">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-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)">Daily Reading Minutes</h3>
|
||
<div class="h-64">
|
||
<canvas id="stats-daily-minutes-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="card p-6" id="stats-pages-chart-panel">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)" id="stats-pages-chart-label">Pages per Day</h3>
|
||
<div class="h-64">
|
||
<canvas id="stats-daily-pages-chart"></canvas>
|
||
</div>
|
||
</div>
|
||
<div class="card p-6 hidden" id="stats-listening-chart-panel">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide mb-4" style="color: var(--text-secondary)">Listening per Day (hrs)</h3>
|
||
<div class="h-64">
|
||
<canvas id="stats-daily-listening-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, plus a Web bucket for pushes that carry no device credential (the web reader).</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>
|
||
<!-- LIBRARY tab — media_items inventory stats (what's IN the
|
||
scoped pool), no time dimension; ABS-style general book
|
||
stats. Data arrives client-side; see stats.ts -->
|
||
<div id="stats-library-section" class="hidden">
|
||
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-3">
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm" for="stats-library-select" style="color: var(--text-secondary)">Library</label>
|
||
<select id="stats-library-select" @change="selectLibrary($event.target.value)" class="btn btn-secondary text-sm bg-transparent">
|
||
<option value="">All libraries</option>
|
||
</select>
|
||
</div>
|
||
<p class="text-xs" style="color: var(--text-secondary)" id="stats-library-scope-label">Inventory statistics — what's in the library, not when it was read.</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)">Items</h3>
|
||
<p id="lib-stat-items" 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)" id="lib-stat-time-label">Hours</h3>
|
||
<p id="lib-stat-time" 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)">Authors</h3>
|
||
<p id="lib-stat-authors" 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)">Size (GB)</h3>
|
||
<p id="lib-stat-size" 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)">Narrators</h3>
|
||
<p id="lib-stat-narrators" class="text-3xl font-bold" style="color: var(--accent)">–</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)">Top Genres</h3>
|
||
<div id="stats-lib-genres" 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)">Top Authors</h3>
|
||
<div id="stats-lib-authors" class="space-y-3">
|
||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||
</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)" id="stats-lib-longest-label">Longest Items (hrs)</h3>
|
||
<div id="stats-lib-longest" 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)">Largest Items</h3>
|
||
<div id="stats-lib-largest" class="space-y-3">
|
||
<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
@ErrorToast("")
|
||
</body>
|
||
</html>
|
||
}
|