feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab

The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.

- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
  enforced: a library_id outside the requester's visible set is a 403,
  never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
  (archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
  client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
  are the merged currency everywhere honest; only the PAGES unit splits
  — Pages card goes mode-aware (Pages · text / Listening Time in an
  audio-only pool) and the charts row gains Listening per Day (hrs)
  when audio minutes exist in the range; summary daily buckets gain
  audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
  existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
  (differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md

Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
This commit is contained in:
John O'Keefe committed 2026-10-16 10:20:17 -04:00
1 parent a222ef5c27
commit ce366b1604
13 files changed
+1493 -78

No files matched your search

+359 -10
View File
@@ -5,10 +5,21 @@ 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;
}
@@ -55,6 +66,7 @@ interface Summary {
start_date: string;
end_date: string;
total_minutes: number;
total_audio_minutes: number;
total_pages: number;
session_count: number;
books_with_activity: number;
@@ -71,13 +83,57 @@ interface Summary {
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 -----------------------------------------------------
@@ -110,6 +166,53 @@ function selectRange(range: string): void {
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;
@@ -150,6 +253,47 @@ function themeVar(name: string, fallback: string): string {
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);
@@ -158,7 +302,7 @@ function renderSummary(summary: Summary): void {
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}`);
renderPagesCard(summary);
text(
"stats-streaks",
summary.streak.current > 0 || summary.streak.longest > 0
@@ -203,8 +347,10 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
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) {
@@ -238,6 +384,27 @@ async function renderDailyCharts(daily: DailyEntry[]): Promise<void> {
options: baseOptions,
});
}
// 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: baseOptions,
});
}
}
function renderLibraries(rows: LibraryRow[]): void {
@@ -246,7 +413,7 @@ function renderLibraries(rows: LibraryRow[]): void {
renderBars(container, rows.map((r) => ({
label: r.name,
sub: `${fmtMinutes(r.minutes)} · ${r.sessions} sessions`,
minutes: r.minutes,
value: r.minutes,
})));
}
@@ -256,7 +423,7 @@ function renderDevices(rows: DeviceRow[]): void {
renderBars(container, rows.map((r) => ({
label: r.name || "Web",
sub: `${r.books} ${r.books === 1 ? "book" : "books"} · ${r.sessions} sessions`,
minutes: r.minutes,
value: r.minutes,
})));
}
@@ -267,17 +434,19 @@ function fmtMinutes(minutes: number): string {
}
function renderBars(
container: HTMLElement,
rows: Array<{ label: string; sub: string; minutes: number }>,
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)">Nothing recorded in this range yet.</p>`;
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.minutes), 1);
const max = Math.max(...rows.map((r) => r.value), 1);
container.innerHTML = rows
.map((row) => {
const pct = Math.round((row.minutes / max) * 100);
const pct = Math.round((row.value / max) * 100);
return `
<div>
<div class="flex justify-between text-sm mb-1">
@@ -316,6 +485,176 @@ function renderTopBooks(books: TopBook[]): void {
.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;")
@@ -325,16 +664,26 @@ function escapeHtml(value: string): string {
}
function initStatsPage(): void {
const initial = new URLSearchParams(window.location.search).get("range") || "7";
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 };
export { initStatsPage, loadSummary, renderSummary, selectRange, selectTab, selectLibrary };
Alpine.data("stats", () => ({
initStatsPage,
loadSummary,
selectRange,
selectTab,
selectLibrary,
}));