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:
1 parent
8de8883acf
commit
6457fbaf72
28 files changed
+3061
-814
No files matched your search
@@ -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
@@ -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";
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
Reference in new issue
Block a user