Files
bookhoard/web/src/stats.ts
T
John O'Keefe b4ae42326a fix(stats): unambiguous chart x-labels + unit-carrying tooltips
The daily charts' x-axis labels were bare MM-DD ('10-15', '10-16') — on
the Listening per Day chart (y in hours) those read as hour values, which
is exactly the '10-16 hours a day' misread that prompted this. Labels now
spell the month ('Oct 15') and every chart's tooltip carries its unit
(min / pages / hrs). main.js?v=5
2026-10-16 11:53:56 -04:00

706 lines
24 KiB
TypeScript
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.
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<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();
}
// Tabs ---------------------------------------------------------------------
function markTabButtons(active: string): void {
document.querySelectorAll<HTMLElement>(".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<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 {
// "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)<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);
renderDevices(summary.device_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));
// 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 = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">${emptyText}</p>`;
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 `
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium" style="color: var(--text-primary)">${escapeHtml(row.label)}</span>
<span style="color: var(--text-secondary)">${row.sub}</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("");
}
// Library tab ---------------------------------------------------------------
async function loadLibraries(): Promise<VisibleLibrary[]> {
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 = `<option value="">All libraries</option>` +
rows
.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`)
.join("");
// Honor a URL-seeded selection initStatsPage may have set.
if (currentLibraryID) select.value = currentLibraryID;
}
async function loadLibraryUsage(): Promise<void> {
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 = `<div class="text-center py-6 text-sm" style="color: var(--text-secondary)">Loading…</div>`;
}
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 = `<p class="text-sm text-center py-6" style="color: var(--text-secondary)">Nothing to rank in this pool yet.</p>`;
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 `
<a href="/media/${item.media_item_id}" class="block p-2 rounded-lg hover:bg-surface-hover">
<div class="flex items-center gap-3">
<span class="w-10 h-14 rounded overflow-hidden shrink-0" style="background-color: var(--bg-primary)">
<img src="${item.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(item.title)}</span>
<span class="block text-xs truncate" style="color: var(--text-secondary)">${escapeHtml(item.author || "")}</span>
</span>
<span class="text-xs shrink-0" style="color: var(--text-secondary)">${i + 1}.</span>
<span class="text-sm font-bold shrink-0" style="color: var(--text-primary)">${escapeHtml(value)}</span>
</div>
<div class="h-2 rounded-full overflow-hidden mt-2" style="background-color: var(--bg-primary)">
<div class="h-full rounded-full" style="width: ${barPct}%; background-color: var(--accent)"></div>
</div>
</a>`;
})
.join("");
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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,
}));