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
706 lines
24 KiB
TypeScript
706 lines
24 KiB
TypeScript
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("&", "&")
|
||
.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,
|
||
}));
|