feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card

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.
This commit is contained in:
John O'Keefe committed 2026-10-14 12:46:38 -04:00
1 parent 8de8883acf
commit 6457fbaf72
28 files changed
+3061 -814

No files matched your search

-129
View File
@@ -1,129 +0,0 @@
import { showToast } from "./toast";
import { Alpine } from "./alpine";
async function loadAnalytics(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const [statsRes, devicesRes, popularRes] = await Promise.all([
fetch("/api/analytics/stats", {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("/api/analytics/devices", {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("/api/analytics/popular", {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (statsRes.ok) {
const stats: ReadingStatsResponse = await statsRes.json();
renderReadingStats(stats);
}
if (devicesRes.ok) {
const devices: DeviceUsageResponse = await devicesRes.json();
renderDeviceUsage(devices);
}
if (popularRes.ok) {
const popular: PopularBooksResponse = await popularRes.json();
renderPopularBooks(popular);
}
} catch (error) {
console.error("Failed to load analytics:", error);
showToast("Failed to load analytics data", "error");
}
}
function renderReadingStats(stats: ReadingStatsResponse): void {
const container = document.getElementById("reading-stats");
if (!container) return;
container.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_books_read}</p>
<p class="text-sm" style="color: var(--text-secondary)">Books Read</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_pages_read}</p>
<p class="text-sm" style="color: var(--text-secondary)">Pages Read</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${stats.total_reading_time_minutes}</p>
<p class="text-sm" style="color: var(--text-secondary)">Minutes Reading</p>
</div>
<div class="stat-card p-4 rounded-lg" style="background-color: var(--bg-secondary)">
<p class="text-2xl font-bold" style="color: var(--text-primary)">${Math.round(stats.completion_rate * 100)}%</p>
<p class="text-sm" style="color: var(--text-secondary)">Completion Rate</p>
</div>
</div>
`;
}
function renderDeviceUsage(devices: DeviceUsageResponse): void {
const container = document.getElementById("device-usage");
if (!container) return;
if (!devices.devices || devices.devices.length === 0) {
container.innerHTML =
'<p style="color: var(--text-secondary)">No device usage data available</p>';
return;
}
container.innerHTML = devices.devices
.map(
(device) => `
<div class="p-3 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center">
<div>
<p class="font-medium" style="color: var(--text-primary)">${device.device_name}</p>
<p class="text-sm" style="color: var(--text-secondary)">${device.device_type}</p>
</div>
<div class="text-right">
<p class="font-medium" style="color: var(--text-primary)">${Math.round(device.total_time_minutes)} min</p>
<p class="text-sm" style="color: var(--text-secondary)">${device.sync_count} syncs</p>
</div>
</div>
</div>
`,
)
.join("");
}
function renderPopularBooks(popular: PopularBooksResponse): void {
const container = document.getElementById("popular-books");
if (!container) return;
if (!popular.books || popular.books.length === 0) {
container.innerHTML =
'<p style="color: var(--text-secondary)">No reading history available</p>';
return;
}
container.innerHTML = popular.books
.map(
(book) => `
<div class="p-3 rounded-lg border flex items-center space-x-3" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex-1">
<p class="font-medium" style="color: var(--text-primary)">${book.title}</p>
<p class="text-sm" style="color: var(--text-secondary)">${book.author}</p>
</div>
<div class="text-right">
<p class="font-medium" style="color: var(--text-primary)">${book.read_count}x</p>
<p class="text-sm" style="color: var(--text-secondary)">${Math.round(book.avg_completion * 100)}%</p>
</div>
</div>
`,
)
.join("");
}
export { loadAnalytics };
Alpine.data("analytics", () => ({
loadAnalytics,
}));
+1 -1
View File
@@ -2,7 +2,6 @@ import "./alpine";
import { Alpine } from "./alpine";
import "./admin";
import "./analytics";
import "./api";
import "./api-explorer";
import "./api-explorer-docs";
@@ -31,6 +30,7 @@ import "./register";
import "./search";
import "./series";
import "./setup";
import "./stats";
import "./storage";
import "./theme";
import "./toast";
+305
View File
@@ -0,0 +1,305 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import type { ChartConfiguration } from "chart.js";
// Reading statistics page (/stats): one summary fetch per range, charts
// hydrated client-side. Chart.js is dynamically imported so the chunk
// only loads on this page.
interface DailyEntry {
date: string;
minutes: number;
pages: number;
sessions: number;
}
interface Streak {
current: number;
longest: number;
active_today: boolean;
}
interface TopBook {
media_item_id: string;
title: string;
author: string;
cover_image_path: string;
minutes: number;
pages: number;
sessions: number;
last_read: string;
}
interface LibraryRow {
library_id: string;
name: string;
minutes: number;
pages: number;
sessions: number;
books: number;
}
interface Summary {
range: string;
days: number;
start_date: string;
end_date: string;
total_minutes: number;
total_pages: number;
session_count: number;
books_with_activity: number;
books_finished: number;
completion_rate: number;
average_session_minutes: number;
daily: DailyEntry[];
streak: Streak;
most_active_weekday: string;
most_active_day: string;
most_active_day_minutes: number;
top_books: TopBook[];
library_breakdown: LibraryRow[];
}
interface ChartLike {
destroy(): void;
}
let minutesChart: ChartLike | null = null;
let pagesChart: ChartLike | null = null;
let currentRange = "7";
// Page-scoped helpers -----------------------------------------------------
async function chartCtor(): Promise<new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike> {
const mod = await import("chart.js/auto");
return (mod as unknown as { default: new (el: HTMLCanvasElement, cfg: ChartConfiguration) => ChartLike }).default;
}
function text(id: string, value: string): void {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function getActiveRange(): string {
const btn = document.querySelector(".stats-range-btn.btn-active") as HTMLElement | null;
return btn?.dataset.range || "7";
}
function markRangeButtons(active: string): void {
document.querySelectorAll<HTMLElement>(".stats-range-btn").forEach((btn) => {
const isActive = btn.dataset.range === active;
btn.classList.toggle("btn-active", isActive);
btn.classList.toggle("opacity-50", !isActive);
});
}
function selectRange(range: string): void {
markRangeButtons(range);
currentRange = range;
void loadSummary();
}
async function loadSummary(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
document.getElementById("stats-empty")?.classList.add("hidden");
const windowLabel = document.getElementById("stats-window-label");
if (windowLabel) windowLabel.textContent = "Loading…";
try {
const res = await fetch(`/api/stats/summary?range=${encodeURIComponent(currentRange)}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 400) {
const err = await res.json();
showToast(err.error || "Invalid range", "error");
if (windowLabel) windowLabel.textContent = "";
return;
}
if (!res.ok) throw new Error(String(res.status));
renderSummary((await res.json()) as Summary);
} catch (error) {
console.error("Failed to load stats:", error);
showToast("Failed to load reading stats", "error");
if (windowLabel) windowLabel.textContent = "";
}
}
// Rendering ----------------------------------------------------------------
function fmtDay(iso: string): string {
const parts = iso.split("-");
return parts.length >= 3 ? `${parts[1]}-${parts[2]}` : iso;
}
function themeVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function renderSummary(summary: Summary): void {
const hasData = summary.total_minutes > 0 || summary.session_count > 0;
document.getElementById("stats-empty")?.classList.toggle("hidden", hasData);
const windowLabel = document.getElementById("stats-window-label");
if (windowLabel) windowLabel.textContent = `${summary.start_date} → ${summary.end_date} (${summary.days} days)`;
const mins = summary.total_minutes;
text("stats-total-minutes", mins >= 60 ? `${Math.floor(mins / 60)} h ${mins % 60} min` : `${mins} min`);
text("stats-total-pages", `${summary.total_pages}`);
text(
"stats-streaks",
summary.streak.current > 0 || summary.streak.longest > 0
? `🔥 ${summary.streak.current} · best ${summary.streak.longest}`
: "—",
);
text(
"stats-books-finished",
summary.books_with_activity > 0 ? `${summary.books_finished} of ${summary.books_with_activity}` : "–",
);
const mostActive = document.getElementById("stats-most-active");
if (mostActive) {
if (summary.most_active_day) {
mostActive.innerHTML = `${summary.most_active_day} (${summary.most_active_day_minutes} min)<span class="block text-xs font-normal" style="color: ${themeVar("--text-secondary", "#999")}">${summary.most_active_weekday}</span>`;
} else {
mostActive.textContent = "—";
}
}
void renderDailyCharts(summary.daily);
renderLibraries(summary.library_breakdown);
renderTopBooks(summary.top_books);
}
async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
const ChartCtor = await chartCtor();
const accent = themeVar("--accent", "#7aa2f7");
const secondary = themeVar("--text-secondary", "#9aa0b5");
const labels = daily.map((d) => fmtDay(d.date));
const baseOptions: ChartConfiguration["options"] = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { display: false }, ticks: { color: secondary, maxTicksLimit: 10 } },
y: { grid: { color: "rgba(128,128,128,.15)" }, ticks: { color: secondary }, beginAtZero: true },
},
};
minutesChart?.destroy();
pagesChart?.destroy();
minutesChart = null;
pagesChart = null;
const minutesCanvas = document.getElementById("stats-daily-minutes-chart") as HTMLCanvasElement | null;
if (minutesCanvas) {
minutesChart = new ChartCtor(minutesCanvas, {
type: "bar",
data: {
labels,
datasets: [{ data: daily.map((d) => d.minutes), backgroundColor: accent, borderRadius: 4 }],
},
options: baseOptions,
});
}
const pagesCanvas = document.getElementById("stats-daily-pages-chart") as HTMLCanvasElement | null;
if (pagesCanvas) {
pagesChart = new ChartCtor(pagesCanvas, {
type: "line",
data: {
labels,
datasets: [
{
data: daily.map((d) => d.pages),
borderColor: accent,
backgroundColor: `${accent}33`,
fill: true,
tension: 0.25,
pointRadius: 2,
},
],
},
options: baseOptions,
});
}
}
function renderLibraries(rows: LibraryRow[]): void {
const container = document.getElementById("stats-library-breakdown");
if (!container) return;
if (rows.length === 0) {
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No library activity in this range.</p>`;
return;
}
const max = Math.max(...rows.map((r) => r.minutes), 1);
container.innerHTML = rows
.map((row) => {
const pct = Math.round((row.minutes / max) * 100);
const time =
row.minutes >= 60
? `${Math.floor(row.minutes / 60)}h${row.minutes % 60 ? ` ${(row.minutes % 60)}m` : ""}`
: `${row.minutes} min`;
return `
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.name)}</span>
<span style="color: var(--text-secondary)">${time} · ${row.sessions} sessions</span>
</div>
<div class="h-2 rounded-full overflow-hidden" style="background-color: var(--bg-primary)">
<div class="h-full rounded-full" style="width: ${pct}%; background-color: var(--accent)"></div>
</div>
</div>`;
})
.join("");
}
function renderTopBooks(books: TopBook[]): void {
const container = document.getElementById("stats-top-books");
if (!container) return;
if (books.length === 0) {
container.innerHTML = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">No books read in this range.</p>`;
return;
}
container.innerHTML = books
.map(
(book) => `
<a href="/media/${book.media_item_id}" class="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${book.cover_image_path || "/static/placeholder-book.svg"}" alt="" class="w-full h-full object-cover" loading="lazy" onerror="this.style.display='none'"/>
</span>
<span class="flex-1 min-w-0">
<span class="block text-sm font-medium truncate" style="color: var(--text-primary)">${escapeHtml(book.title)}</span>
<span class="block text-xs truncate" style="color: var(--text-secondary)">${escapeHtml(book.author || "")}</span>
</span>
<span class="text-xs text-right shrink-0" style="color: var(--text-secondary)">${book.minutes} min<br/>${book.sessions} sessions</span>
</a>`,
)
.join("");
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function initStatsPage(): void {
const initial = new URLSearchParams(window.location.search).get("range") || "7";
currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7";
markRangeButtons(currentRange);
void loadSummary();
}
export { initStatsPage, loadSummary, renderSummary, selectRange };
Alpine.data("stats", () => ({
initStatsPage,
loadSummary,
selectRange,
}));