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:
1 parent
a222ef5c27
commit
ce366b1604
13 files changed
+1493
-78
No files matched your search
+359
-10
@@ -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("&", "&")
|
||||
@@ -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,
|
||||
}));
|
||||
Reference in new issue
Block a user