Files
bookhoard/web/src/stats.ts
T
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00

742 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 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) => {
// Scalar for bar charts, {x, y} points for line charts.
const parsed: number | { y: number } = ctx.parsed;
const v = typeof parsed === "number" ? parsed : 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,
}));