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. // // Two tabs: // - Reading — behavior stats from sync pushes, all libraries. Days // bucket in the user's timezone. Minutes are the merged currency // (audio wall-clock counts honestly); only the PAGES unit splits // side-by-side (Pages · text vs Listening per Day), because a // listen-only day must never render as a lying zero. // - Library — media_items INVENTORY stats for one library (or the // All-libraries aggregate): items, hours/pages, authors, size, // narrators, top genres/authors, longest/largest items. interface DailyEntry { date: string; minutes: number; audio_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 DeviceRow { device_id: string; name: string; type: 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_audio_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[]; device_breakdown?: DeviceRow[]; } // Library tab payload (GET /api/stats/library-usage) ---------------------- interface NameCount { name: string; count: number; } interface UsageItem { media_item_id: string; title: string; author: string; cover_image_path: string; seconds: number; // audio duration (hours-mode) pages: number; // text pages (pages-mode) size: number; // always set } interface LibraryUsage { library_id: string; // "" = all visible libraries items: number; authors: number; narrators: number; total_size_bytes: number; total_seconds: number; total_pages: number; hours_mode: boolean; genres: NameCount[]; top_authors: NameCount[]; longest_items: UsageItem[]; largest_items: UsageItem[]; } interface VisibleLibrary { id: string; name: string; type_name: string; is_visible: boolean; } interface ChartLike { destroy(): void; } let minutesChart: ChartLike | null = null; let pagesChart: ChartLike | null = null; let listeningChart: ChartLike | null = null; let currentRange = "7"; let currentTab = "reading"; let currentLibraryID = ""; // "" = All libraries let libraryOptionsLoaded = false; let libraryDataLoaded = false; // Page-scoped helpers ----------------------------------------------------- async function chartCtor(): Promise 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(".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(); } // Tabs --------------------------------------------------------------------- function markTabButtons(active: string): void { document.querySelectorAll(".stats-tab-btn").forEach((btn) => { const isActive = btn.dataset.tab === active; btn.classList.toggle("btn-active", isActive); btn.classList.toggle("opacity-50", !isActive); }); } function selectTab(tab: string): void { currentTab = tab === "library" ? "library" : "reading"; markTabButtons(currentTab); const reading = document.getElementById("stats-reading-section"); const library = document.getElementById("stats-library-section"); reading?.classList.toggle("hidden", currentTab !== "reading"); library?.classList.toggle("hidden", currentTab !== "library"); // Reading tab state addressable via the URL either way; the library // selection rides its own param (set in selectLibrary). syncUrl(); if (currentTab === "library" && !libraryOptionsLoaded) { void loadLibraries(); } if (currentTab === "library" && !libraryDataLoaded) { void loadLibraryUsage(); } } function selectLibrary(libraryID: string): void { currentLibraryID = libraryID || ""; libraryDataLoaded = false; syncUrl(); void loadLibraryUsage(); } function syncUrl(): void { const params = new URLSearchParams(); if (currentTab !== "reading") params.set("tab", currentTab); if (currentLibraryID) params.set("library_id", currentLibraryID); const qs = params.toString(); window.history.replaceState(null, "", window.location.pathname + (qs ? `?${qs}` : "")); } // Summary fetch (Reading tab) ----------------------------------------------- async function loadSummary(): Promise { 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 { // "2026-10-15" → "Oct 15" — a bare "10-15" x-label reads like a NUMBER // RANGE next to hour values (it was mistaken for "10-15 hours" on the // listening chart), so spell the month out. const d = new Date(iso + "T00:00:00"); if (isNaN(d.getTime())) return iso; return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); } function themeVar(name: string, fallback: string): string { const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return value || fallback; } function fmtHoursLabel(seconds: number): string { return `${(seconds / 3600).toFixed(1)}`; } /** * Mode-aware wording for the Pages card (the only top-line widget that * carries a unit audio cannot share): in a mixed pool it counts TEXT * pages explicitly; in an audio-only pool the slot carries the TIME * equivalent instead of a lying zero. Display-time only — the source * data is untouched. */ function renderPagesCard(summary: Summary): void { const hasText = summary.total_pages > 0; const hasAudio = summary.total_audio_minutes > 0; const cardLabel = document.getElementById("stats-pages-card-label"); const chartLabel = document.getElementById("stats-pages-chart-label"); const pagesPanel = document.getElementById("stats-pages-chart-panel"); const listeningPanel = document.getElementById("stats-listening-chart-panel"); let label: string; let value: string; if (hasText) { label = hasAudio ? "Pages · text" : "Pages"; value = `${summary.total_pages}`; } else if (hasAudio) { label = "Listening Time"; value = fmtMinutes(summary.total_audio_minutes); } else { label = "Pages"; value = "0"; } if (cardLabel) cardLabel.textContent = label; text("stats-total-pages", value); if (chartLabel) chartLabel.textContent = hasAudio ? "Text Pages per Day" : "Pages per Day"; // Charts row: Listening per Day appears only when audio exists in the // range; the text-pages chart drops out of an audio-only range. pagesPanel?.classList.toggle("hidden", !hasText && hasAudio); listeningPanel?.classList.toggle("hidden", !hasAudio); } 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`); renderPagesCard(summary); 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)${summary.most_active_weekday}`; } else { mostActive.textContent = "—"; } } void renderDailyCharts(summary.daily); renderLibraries(summary.library_breakdown); renderDevices(summary.device_breakdown || []); renderTopBooks(summary.top_books); } async function renderDailyCharts(daily: DailyEntry[]): Promise { const ChartCtor = await chartCtor(); const accent = themeVar("--accent", "#7aa2f7"); const secondary = themeVar("--text-secondary", "#9aa0b5"); const labels = daily.map((d) => fmtDay(d.date)); // Unit-carrying tooltips: a bare number next to "hrs"/"min"/"pages" // invites misreading (the x-axis date confusion happened exactly once). const chartOptions = (unit: string): ChartConfiguration["options"] => ({ responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => { const v = typeof ctx.parsed === "number" ? ctx.parsed : ctx.parsed.y; return `${Math.round(v * 10) / 10} ${unit}`; }, }, }, }, 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(); listeningChart?.destroy(); minutesChart = null; pagesChart = null; listeningChart = 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: chartOptions("min"), }); } 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: chartOptions("pages"), }); } // Listening per Day (hrs) — the audio half of the side-by-side pages // split; its panel is hidden by renderPagesCard unless the range has // audio minutes. const listeningCanvas = document.getElementById("stats-daily-listening-chart") as HTMLCanvasElement | null; if (listeningCanvas) { listeningChart = new ChartCtor(listeningCanvas, { type: "bar", data: { labels, datasets: [ { data: daily.map((d) => (d.audio_minutes || 0) / 60), backgroundColor: accent, borderRadius: 4, }, ], }, options: chartOptions("hrs"), }); } } function renderLibraries(rows: LibraryRow[]): void { const container = document.getElementById("stats-library-breakdown"); if (!container) return; renderBars(container, rows.map((r) => ({ label: r.name, sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`, value: r.minutes, }))); } function renderDevices(rows: DeviceRow[]): void { const container = document.getElementById("stats-device-breakdown"); if (!container) return; renderBars(container, rows.map((r) => ({ label: r.name || "Web", sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`, value: r.minutes, }))); } function fmtMinutes(minutes: number): string { return minutes >= 60 ? `${Math.floor(minutes / 60)}h${minutes % 60 ? ` ${(minutes % 60)}m` : ""}` : `${minutes} min`; } function renderBars( container: HTMLElement | null, rows: Array<{ label: string; sub: string; value: number }>, emptyText = "Nothing recorded in this range yet.", ): void { if (!container) return; if (rows.length === 0) { container.innerHTML = `

${emptyText}

`; return; } const max = Math.max(...rows.map((r) => r.value), 1); container.innerHTML = rows .map((row) => { const pct = Math.round((row.value / max) * 100); return `
${escapeHtml(row.label)} ${row.sub}
`; }) .join(""); } function renderTopBooks(books: TopBook[]): void { const container = document.getElementById("stats-top-books"); if (!container) return; if (books.length === 0) { container.innerHTML = `

No books read in this range.

`; return; } container.innerHTML = books .map( (book) => ` ${escapeHtml(book.title)} ${escapeHtml(book.author || "")} ${book.minutes} min
${book.sessions} sessions
`, ) .join(""); } // Library tab --------------------------------------------------------------- async function loadLibraries(): Promise { const token = localStorage.getItem("token"); if (!token) return []; const res = await fetch("/api/libraries/visibility", { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) { showToast("Failed to load libraries", "error"); return []; } const rows = (await res.json()) as VisibleLibrary[]; libraryOptionsLoaded = true; fillLibrarySelector(rows); return rows; } function fillLibrarySelector(rows: VisibleLibrary[]): void { const select = document.getElementById("stats-library-select") as HTMLSelectElement | null; if (!select) return; select.innerHTML = `` + rows .map((r) => ``) .join(""); // Honor a URL-seeded selection initStatsPage may have set. if (currentLibraryID) select.value = currentLibraryID; } async function loadLibraryUsage(): Promise { if (libraryDataLoaded) return; const token = localStorage.getItem("token"); if (!token) return; libraryDataLoaded = true; const qs = currentLibraryID ? `?library_id=${encodeURIComponent(currentLibraryID)}` : ""; const targets = [ "lib-stat-items", "lib-stat-time", "lib-stat-authors", "lib-stat-size", "lib-stat-narrators", ]; targets.forEach((id) => text(id, "–")); for (const id of ["stats-lib-genres", "stats-lib-authors", "stats-lib-longest", "stats-lib-largest"]) { const el = document.getElementById(id); if (el) el.innerHTML = `
Loading…
`; } try { const res = await fetch(`/api/stats/library-usage${qs}`, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 403) { showToast("Library not visible to you", "error"); return; } if (!res.ok) throw new Error(String(res.status)); renderLibraryUsage((await res.json()) as LibraryUsage); } catch (error) { libraryDataLoaded = false; // allow a retry on next activation console.error("Failed to load library stats:", error); showToast("Failed to load library stats", "error"); } } function fmtBytes(bytes: number): string { if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`; if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB`; return `${bytes} B`; } function renderLibraryUsage(payload: LibraryUsage): void { const scopeLabel = document.getElementById("stats-library-scope-label"); if (scopeLabel) { scopeLabel.textContent = payload.library_id ? "Inventory statistics for this library — what's in it, not when it was read." : "Inventory statistics across every visible library."; } text("lib-stat-items", `${payload.items}`); text("lib-stat-authors", `${payload.authors}`); text("lib-stat-narrators", `${payload.narrators}`); // The Hours/Pages card adapts to the scope: audiobooks carry real // durations; text libraries rank in pages instead. const timeLabel = document.getElementById("lib-stat-time-label"); if (payload.hours_mode) { if (timeLabel) timeLabel.textContent = "Hours"; text("lib-stat-time", fmtHoursLabel(payload.total_seconds)); } else { if (timeLabel) timeLabel.textContent = "Pages"; text("lib-stat-time", `${payload.total_pages}`); } const gb = payload.total_size_bytes / 1e9; text("lib-stat-size", gb >= 0.1 ? gb.toFixed(1) : (payload.total_size_bytes / 1e6).toFixed(1) + " MB"); const itemTotal = Math.max(payload.items, 1); renderBars( document.getElementById("stats-lib-genres"), payload.genres.map((g) => ({ label: g.name, sub: `${g.count} ${g.count === 1 ? "item" : "items"} · ${Math.round((g.count / itemTotal) * 100)}%`, value: g.count, })), "No genres recorded in this pool yet.", ); renderBars( document.getElementById("stats-lib-authors"), payload.top_authors.map((a) => ({ label: a.name, sub: `${a.count} ${a.count === 1 ? "book" : "books"} · ${Math.round((a.count / itemTotal) * 100)}%`, value: a.count, })), "No authors recorded in this pool yet.", ); renderUsageItems("stats-lib-longest", "stats-lib-longest-label", payload.longest_items, payload.hours_mode); renderUsageItems("stats-lib-largest", null, payload.largest_items, payload.hours_mode, true); } function renderUsageItems( containerID: string, labelID: string | null, items: UsageItem[], hoursMode: boolean, largest = false, ): void { const container = document.getElementById(containerID); if (!container) return; if (labelID && !largest) { const label = document.getElementById(labelID); if (label) label.textContent = hoursMode ? "Longest Items (hrs)" : "Longest Items (pages)"; } if (items.length === 0) { container.innerHTML = `

Nothing to rank in this pool yet.

`; return; } const values = items.map((i) => (largest ? i.size : hoursMode ? i.seconds : i.pages)); const max = Math.max(...values, 1); container.innerHTML = items .map((item, i) => { const value = largest ? fmtBytes(item.size) : hoursMode ? (item.seconds / 3600).toFixed(1) : `${item.pages}`; const barPct = Math.round((values[i] / max) * 100); return `
${escapeHtml(item.title)} ${escapeHtml(item.author || "")} ${i + 1}. ${escapeHtml(value)}
`; }) .join(""); } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function initStatsPage(): void { const params = new URLSearchParams(window.location.search); const initial = params.get("range") || "7"; currentRange = ["7", "30", "90", "year"].includes(initial) ? initial : "7"; markRangeButtons(currentRange); const libParam = params.get("library_id") || ""; if (/^[0-9a-f-]{36}$/i.test(libParam)) currentLibraryID = libParam; markTabButtons("reading"); void loadSummary(); selectTab(params.get("tab") || "reading"); } export { initStatsPage, loadSummary, renderSummary, selectRange, selectTab, selectLibrary }; Alpine.data("stats", () => ({ initStatsPage, loadSummary, selectRange, selectTab, selectLibrary, }));