From 58d110a28e18aa5b5c1f082b6d23fa3f20dceee5 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Sat, 18 Apr 2026 01:18:26 +0200 Subject: [PATCH 1/4] feat(library): add statistics dashboard panel (#124) Add a lazy-loaded statistics panel to the Library view that shows aggregate metrics across the entire manuscript collection. - new component library_stats.py with render_library_stats() - metrics: downloaded pages (count + %), transcribed pages, OCR pages, disk usage (human-readable bytes) - provider distribution via pure-CSS percentage bars, top 8 libraries - transcription and OCR coverage computed by scanning per-manuscript transcription.json files (engine + is_manual fields) - disk usage computed by summing file sizes under each local_path - loaded lazily via hx-get="/api/library/stats" hx-trigger="load" so the main Library page is not blocked - new GET /api/library/stats route; stats always reflect the full collection regardless of active filters Co-Authored-By: Claude Sonnet 4.6 --- src/studio_ui/components/library.py | 6 + src/studio_ui/components/library_stats.py | 161 ++++++++++++++++++++++ src/studio_ui/routes/library.py | 1 + src/studio_ui/routes/library_handlers.py | 7 + 4 files changed, 175 insertions(+) create mode 100644 src/studio_ui/components/library_stats.py diff --git a/src/studio_ui/components/library.py b/src/studio_ui/components/library.py index fbf7de3..6a789f7 100644 --- a/src/studio_ui/components/library.py +++ b/src/studio_ui/components/library.py @@ -60,6 +60,12 @@ def render_library_page( cls="flex items-center justify-between mb-4", ), _kpi_strip(docs), + Div( + id="library-stats-panel", + hx_get="/api/library/stats", + hx_trigger="load", + hx_swap="outerHTML", + ), _render_filters( view=view, q=q, diff --git a/src/studio_ui/components/library_stats.py b/src/studio_ui/components/library_stats.py new file mode 100644 index 0000000..6423916 --- /dev/null +++ b/src/studio_ui/components/library_stats.py @@ -0,0 +1,161 @@ +"""Library statistics panel component.""" + +from __future__ import annotations + +import json +from collections import Counter +from contextlib import suppress +from pathlib import Path + +from fasthtml.common import Div, P, Span + +from universal_iiif_core.logger import get_logger + +logger = get_logger(__name__) + +_CARD_CLS = "rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/60 p-3" + + +def _format_bytes(n: int) -> str: + value = float(n) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024: + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} TB" + + +def _pct_str(part: int, total: int) -> str: + if not total: + return "โ€”" + return f"{round(100 * part / total)}%" + + +def _metric_card(label: str, value: str, sub: str = "") -> Div: + children: list = [ + P(label, cls="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1"), + P(value, cls="text-xl font-bold text-slate-800 dark:text-slate-100"), + ] + if sub: + children.append(P(sub, cls="text-xs text-slate-500 dark:text-slate-400 mt-0.5")) + return Div(*children, cls=_CARD_CLS) + + +def _provider_bar_row(name: str, count: int, total: int) -> Div: + pct = round(100 * count / total) if total else 0 + return Div( + Span(name, cls="text-xs text-slate-600 dark:text-slate-300 w-28 truncate shrink-0"), + Div( + Div(cls="h-full rounded bg-indigo-400 dark:bg-indigo-500", style=f"width:{pct}%"), + cls="flex-1 h-2 rounded bg-slate-200 dark:bg-slate-600 overflow-hidden", + ), + Span(str(count), cls="text-xs tabular-nums text-slate-500 dark:text-slate-400 ml-2 shrink-0"), + cls="flex items-center gap-2", + ) + + +def _provider_bars(counts: dict[str, int], total: int) -> Div: + top = sorted(counts.items(), key=lambda x: -x[1])[:8] + return Div( + *[_provider_bar_row(name, count, total) for name, count in top], + cls="flex flex-col gap-1.5", + ) + + +def _read_transcription_file(tx_file: Path) -> list[dict] | None: + try: + data = json.loads(tx_file.read_text(encoding="utf-8")) + return data.get("pages", []) + except Exception: + logger.debug("Skipping unreadable transcription file: %s", tx_file) + return None + + +def _scan_transcriptions(manuscripts: list[dict]) -> tuple[int, int]: + """Return (transcribed_pages, ocr_pages) by reading per-manuscript JSON files.""" + transcribed = 0 + ocr = 0 + for m in manuscripts: + lp = m.get("local_path") + if not lp: + continue + tx_file = Path(lp) / "data" / "transcription.json" + if not tx_file.exists(): + continue + pages = _read_transcription_file(tx_file) + if pages is None: + continue + for page in pages: + if page.get("full_text"): + transcribed += 1 + if not page.get("is_manual"): + ocr += 1 + return transcribed, ocr + + +def _dir_size(path: Path) -> int: + total = 0 + with suppress(OSError): + total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + return total + + +def _scan_disk_usage(manuscripts: list[dict]) -> int: + """Return total bytes used across all local manuscript directories.""" + total = 0 + seen: set[str] = set() + for m in manuscripts: + lp = m.get("local_path") + if not lp or lp in seen: + continue + seen.add(lp) + p = Path(lp) + if p.exists(): + total += _dir_size(p) + return total + + +def render_library_stats(manuscripts: list[dict]) -> Div: + """Render the statistics panel for the Library view (loaded lazily via HTMX).""" + total = len(manuscripts) + total_canvases = sum(int(m.get("total_canvases") or 0) for m in manuscripts) + downloaded = sum(int(m.get("downloaded_canvases") or 0) for m in manuscripts) + provider_counts: Counter[str] = Counter(m.get("library") or "Unknown" for m in manuscripts) + transcribed_pages, ocr_pages = _scan_transcriptions(manuscripts) + disk_bytes = _scan_disk_usage(manuscripts) + + metrics = Div( + _metric_card( + "Pagine scaricate", + f"{downloaded:,} / {total_canvases:,}", + _pct_str(downloaded, total_canvases), + ), + _metric_card( + "Pagine trascritte", + f"{transcribed_pages:,}", + _pct_str(transcribed_pages, total_canvases), + ), + _metric_card( + "Pagine OCR", + f"{ocr_pages:,}", + _pct_str(ocr_pages, total_canvases), + ), + _metric_card("Spazio disco", _format_bytes(disk_bytes)), + cls="grid grid-cols-2 lg:grid-cols-4 gap-3", + ) + + provider_panel = Div( + P( + "Distribuzione per biblioteca", + cls="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2", + ), + _provider_bars(dict(provider_counts), total), + cls=_CARD_CLS, + ) + + return Div( + metrics, + provider_panel if total else Div(), + cls="flex flex-col gap-3 mb-5", + id="library-stats-panel", + ) diff --git a/src/studio_ui/routes/library.py b/src/studio_ui/routes/library.py index 9cb67b7..2e43cf5 100644 --- a/src/studio_ui/routes/library.py +++ b/src/studio_ui/routes/library.py @@ -6,6 +6,7 @@ def setup_library_routes(app): """Register library/local-assets routes.""" app.get("/library")(library_handlers.library_page) + app.get("/api/library/stats")(library_handlers.library_stats_panel) app.post("/api/library/delete")(library_handlers.library_delete) app.post("/api/library/cleanup_partial")(library_handlers.library_cleanup_partial) app.post("/api/library/start_download")(library_handlers.library_start_download) diff --git a/src/studio_ui/routes/library_handlers.py b/src/studio_ui/routes/library_handlers.py index 134f518..0994ca6 100644 --- a/src/studio_ui/routes/library_handlers.py +++ b/src/studio_ui/routes/library_handlers.py @@ -10,6 +10,7 @@ from studio_ui.common.toasts import build_toast from studio_ui.components.layout import base_layout from studio_ui.components.library import render_library_card, render_library_page +from studio_ui.components.library_stats import render_library_stats from studio_ui.routes.discovery_helpers import start_downloader_thread from studio_ui.routes.library_query import ( _collect_docs_and_filters, @@ -162,6 +163,12 @@ def library_page( return base_layout("Libreria", content, active_page="library") +def library_stats_panel(): + """Return the lazy-loaded statistics panel for the Library view.""" + manuscripts = VaultManager().get_all_manuscripts() + return render_library_stats(manuscripts) + + def library_delete( doc_id: str, library: str, From 5cde216a2fcb4681ed3cf0085c4bb74df7d0f51c Mon Sep 17 00:00:00 2001 From: nikazzio Date: Sat, 18 Apr 2026 01:29:22 +0200 Subject: [PATCH 2/4] feat(stats): add sidebar nerd-widget and dedicated /stats page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes inline stats from the Library page and introduces: - Compact DB-only widget in sidebar footer (mss count, pages, % local) loaded lazily via /api/stats/sidebar; hidden when sidebar is collapsed - Dedicated /stats route with fast DB metrics (manuscript count, pages, provider distribution, recent activity) plus lazy /api/stats/detail panel for slow disk + transcription scans - ๐Ÿ“Š Statistiche nav item added to sidebar Closes #124 Co-Authored-By: Claude Sonnet 4.6 --- src/studio_app.py | 4 + src/studio_ui/components/layout.py | 9 +- src/studio_ui/components/library.py | 6 - src/studio_ui/components/library_stats.py | 227 ++++++++++++++++------ src/studio_ui/routes/library.py | 1 - src/studio_ui/routes/library_handlers.py | 7 - src/studio_ui/routes/stats.py | 10 + src/studio_ui/routes/stats_handlers.py | 34 ++++ 8 files changed, 227 insertions(+), 71 deletions(-) create mode 100644 src/studio_ui/routes/stats.py create mode 100644 src/studio_ui/routes/stats_handlers.py diff --git a/src/studio_app.py b/src/studio_app.py index f794589..71cb226 100644 --- a/src/studio_app.py +++ b/src/studio_app.py @@ -16,6 +16,7 @@ from studio_ui.routes.export import setup_export_routes from studio_ui.routes.library import setup_library_routes from studio_ui.routes.settings import setup_settings_routes +from studio_ui.routes.stats import setup_stats_routes from studio_ui.routes.studio import setup_studio_routes from universal_iiif_core import __version__ from universal_iiif_core.config_manager import get_config_manager @@ -171,6 +172,9 @@ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaita # Settings page routes setup_settings_routes(app) +# Statistics page routes +setup_stats_routes(app) + # Root redirect @rt("/") diff --git a/src/studio_ui/components/layout.py b/src/studio_ui/components/layout.py index 6f7c5ff..04073aa 100644 --- a/src/studio_ui/components/layout.py +++ b/src/studio_ui/components/layout.py @@ -321,6 +321,7 @@ def _sidebar(active_page: str = "") -> Nav: ("library", "Libreria", "/library", "๐Ÿ“š"), ("studio", "Studio", "/studio", "๐Ÿงญ"), ("export", "Export", "/export", "๐Ÿ“„"), + ("stats", "Statistiche", "/stats", "๐Ÿ“Š"), ("settings", "Impostazioni", "/settings", "โš™๏ธ"), ] @@ -377,7 +378,13 @@ def _sidebar(active_page: str = "") -> Nav: cls="w-full py-3 px-4 rounded bg-gray-700 hover:bg-gray-600 transition-colors mb-3 text-white", ), Div( - Div(f"v{__version__} โ†’ FastHTML", cls="text-xs text-gray-500"), + Div( + id="sidebar-stats-widget", + hx_get="/api/stats/sidebar", + hx_trigger="load", + hx_swap="outerHTML", + ), + Div(f"v{__version__}", cls="text-xs text-gray-600 sidebar-label"), cls="pt-4 border-t border-gray-700 sidebar-footer", ), Script(""" diff --git a/src/studio_ui/components/library.py b/src/studio_ui/components/library.py index 6a789f7..fbf7de3 100644 --- a/src/studio_ui/components/library.py +++ b/src/studio_ui/components/library.py @@ -60,12 +60,6 @@ def render_library_page( cls="flex items-center justify-between mb-4", ), _kpi_strip(docs), - Div( - id="library-stats-panel", - hx_get="/api/library/stats", - hx_trigger="load", - hx_swap="outerHTML", - ), _render_filters( view=view, q=q, diff --git a/src/studio_ui/components/library_stats.py b/src/studio_ui/components/library_stats.py index 6423916..673b13b 100644 --- a/src/studio_ui/components/library_stats.py +++ b/src/studio_ui/components/library_stats.py @@ -1,22 +1,29 @@ -"""Library statistics panel component.""" +"""Library statistics components โ€” sidebar widget and full stats page.""" from __future__ import annotations import json from collections import Counter from contextlib import suppress +from datetime import datetime, timezone from pathlib import Path -from fasthtml.common import Div, P, Span +from fasthtml.common import H2, A, Div, Li, P, Span, Ul from universal_iiif_core.logger import get_logger logger = get_logger(__name__) -_CARD_CLS = "rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/60 p-3" +_CARD_CLS = "rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/60 p-4" +_SECTION_LABEL_CLS = "text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-3" +_STATE_ICON = {"complete": "โœ…", "partial": "๐Ÿ”ถ", "saved": "๐Ÿ”ต", "downloading": "โณ", "error": "โŒ"} + + +# โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _format_bytes(n: int) -> str: + """Format byte count as human-readable string.""" value = float(n) for unit in ("B", "KB", "MB", "GB"): if value < 1024: @@ -26,40 +33,46 @@ def _format_bytes(n: int) -> str: def _pct_str(part: int, total: int) -> str: + """Format part/total as percentage string.""" if not total: return "โ€”" return f"{round(100 * part / total)}%" -def _metric_card(label: str, value: str, sub: str = "") -> Div: - children: list = [ - P(label, cls="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1"), - P(value, cls="text-xl font-bold text-slate-800 dark:text-slate-100"), - ] - if sub: - children.append(P(sub, cls="text-xs text-slate-500 dark:text-slate-400 mt-0.5")) - return Div(*children, cls=_CARD_CLS) +def _fmt_count(n: int) -> str: + """Format large numbers as compact strings (8400 โ†’ 8.4k).""" + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1000: + return f"{n / 1000:.1f}k" + return str(n) -def _provider_bar_row(name: str, count: int, total: int) -> Div: - pct = round(100 * count / total) if total else 0 - return Div( - Span(name, cls="text-xs text-slate-600 dark:text-slate-300 w-28 truncate shrink-0"), - Div( - Div(cls="h-full rounded bg-indigo-400 dark:bg-indigo-500", style=f"width:{pct}%"), - cls="flex-1 h-2 rounded bg-slate-200 dark:bg-slate-600 overflow-hidden", - ), - Span(str(count), cls="text-xs tabular-nums text-slate-500 dark:text-slate-400 ml-2 shrink-0"), - cls="flex items-center gap-2", - ) +def _time_ago(ts_str: str | None) -> str: + """Return human-readable relative time from an ISO timestamp.""" + if not ts_str: + return "โ€”" + try: + dt = datetime.fromisoformat(str(ts_str).replace("Z", "+00:00")) + delta = datetime.now(timezone.utc) - dt.astimezone(timezone.utc) + days = delta.days + if days == 0: + hours = delta.seconds // 3600 + return "poco fa" if hours == 0 else f"{hours}h fa" + if days == 1: + return "ieri" + if days < 7: + return f"{days}g fa" + if days < 30: + return f"{days // 7}sett fa" + if days < 365: + return f"{days // 30}m fa" + return f"{days // 365}a fa" + except Exception: + return "โ€”" -def _provider_bars(counts: dict[str, int], total: int) -> Div: - top = sorted(counts.items(), key=lambda x: -x[1])[:8] - return Div( - *[_provider_bar_row(name, count, total) for name, count in top], - cls="flex flex-col gap-1.5", - ) +# โ”€โ”€ file-scan helpers (slow โ€” use only in lazy endpoints) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _read_transcription_file(tx_file: Path) -> list[dict] | None: @@ -115,47 +128,149 @@ def _scan_disk_usage(manuscripts: list[dict]) -> int: return total -def render_library_stats(manuscripts: list[dict]) -> Div: - """Render the statistics panel for the Library view (loaded lazily via HTMX).""" +# โ”€โ”€ sidebar widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def render_sidebar_stats_widget(manuscripts: list[dict]) -> Div: + """Render the compact nerd-stats widget for the sidebar footer (DB-only, fast).""" + total = len(manuscripts) + total_canvases = sum(int(m.get("total_canvases") or 0) for m in manuscripts) + downloaded = sum(int(m.get("downloaded_canvases") or 0) for m in manuscripts) + pct = round(100 * downloaded / total_canvases) if total_canvases else 0 + + line1 = f"{total} mss ยท {_fmt_count(total_canvases)} pp" + line2 = f"{pct}% locale" + + return Div( + A( + Div( + Div(line1, cls="font-mono text-xs text-gray-400 leading-snug"), + Div(line2, cls="font-mono text-xs text-gray-500 leading-snug"), + cls="sidebar-label", + ), + href="/stats", + hx_get="/stats", + hx_target="#app-main", + hx_swap="innerHTML", + hx_push_url="true", + cls="block hover:opacity-80 transition-opacity", + title="Statistiche collezione", + ), + id="sidebar-stats-widget", + cls="mb-3", + ) + + +# โ”€โ”€ full stats page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _metric_card(label: str, value: str, sub: str = "") -> Div: + children: list = [ + P(label, cls="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1"), + P(value, cls="text-2xl font-bold font-mono text-slate-800 dark:text-slate-100"), + ] + if sub: + children.append(P(sub, cls="text-xs text-slate-400 dark:text-slate-500 mt-1")) + return Div(*children, cls=_CARD_CLS) + + +def _provider_bar_row(name: str, count: int, total: int) -> Div: + pct = round(100 * count / total) if total else 0 + return Div( + Span(name, cls="text-sm text-slate-600 dark:text-slate-300 w-40 truncate shrink-0"), + Div( + Div(cls="h-full rounded bg-indigo-400 dark:bg-indigo-500", style=f"width:{pct}%"), + cls="flex-1 h-2.5 rounded bg-slate-200 dark:bg-slate-700 overflow-hidden", + ), + Span(str(count), cls="text-xs font-mono tabular-nums text-slate-500 dark:text-slate-400 ml-3 shrink-0"), + cls="flex items-center gap-3", + ) + + +def _recent_activity_row(m: dict) -> Li: + title = str(m.get("display_title") or m.get("catalog_title") or m.get("id") or "โ€”") + library = str(m.get("library") or "โ€”") + state = str(m.get("asset_state") or m.get("status") or "saved") + icon = _STATE_ICON.get(state, "โšช") + when = _time_ago(m.get("updated_at")) + return Li( + Span(icon, cls="shrink-0 text-base"), + Div( + Div(title, cls="text-sm text-slate-700 dark:text-slate-200 truncate"), + Div(f"{library} ยท {when}", cls="text-xs text-slate-500 dark:text-slate-400"), + cls="min-w-0", + ), + cls="flex items-start gap-2 py-1.5 border-b border-slate-100 dark:border-slate-800 last:border-0", + ) + + +def render_stats_page_content(manuscripts: list[dict]) -> Div: + """Render the fast (DB-only) part of the stats page โ€” shown immediately.""" total = len(manuscripts) total_canvases = sum(int(m.get("total_canvases") or 0) for m in manuscripts) downloaded = sum(int(m.get("downloaded_canvases") or 0) for m in manuscripts) provider_counts: Counter[str] = Counter(m.get("library") or "Unknown" for m in manuscripts) + + fast_metrics = Div( + _metric_card("Manoscritti", str(total)), + _metric_card("Pagine totali", _fmt_count(total_canvases)), + _metric_card("Pagine scaricate", _fmt_count(downloaded), _pct_str(downloaded, total_canvases)), + cls="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6", + ) + + top = sorted(provider_counts.items(), key=lambda x: -x[1])[:10] + provider_panel = Div( + P("Distribuzione per biblioteca", cls=_SECTION_LABEL_CLS), + Div(*[_provider_bar_row(n, c, total) for n, c in top], cls="flex flex-col gap-2"), + cls=_CARD_CLS + " mb-6", + ) + + recent = manuscripts[:6] + recent_panel = Div( + P("Ultimi aggiornati", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-2"), + Ul(*[_recent_activity_row(m) for m in recent], cls="divide-y divide-slate-100 dark:divide-slate-800"), + cls=_CARD_CLS + " mb-6", + ) if recent else Div() + + detail_placeholder = Div( + id="stats-detail-panel", + hx_get="/api/stats/detail", + hx_trigger="load", + hx_swap="outerHTML", + ) + + return Div( + Div( + H2("Statistiche Collezione", cls="text-2xl font-bold text-slate-800 dark:text-slate-100"), + cls="mb-6", + ), + fast_metrics, + detail_placeholder, + provider_panel, + recent_panel, + cls="p-6 max-w-4xl mx-auto", + id="stats-page", + ) + + +def render_library_stats(manuscripts: list[dict]) -> Div: + """Render the detail metrics panel (slow โ€” disk + transcription scan).""" + total_canvases = sum(int(m.get("total_canvases") or 0) for m in manuscripts) transcribed_pages, ocr_pages = _scan_transcriptions(manuscripts) disk_bytes = _scan_disk_usage(manuscripts) - metrics = Div( - _metric_card( - "Pagine scaricate", - f"{downloaded:,} / {total_canvases:,}", - _pct_str(downloaded, total_canvases), - ), + return Div( + _metric_card("Spazio disco", _format_bytes(disk_bytes)), _metric_card( "Pagine trascritte", - f"{transcribed_pages:,}", + _fmt_count(transcribed_pages), _pct_str(transcribed_pages, total_canvases), ), _metric_card( "Pagine OCR", - f"{ocr_pages:,}", + _fmt_count(ocr_pages), _pct_str(ocr_pages, total_canvases), ), - _metric_card("Spazio disco", _format_bytes(disk_bytes)), - cls="grid grid-cols-2 lg:grid-cols-4 gap-3", - ) - - provider_panel = Div( - P( - "Distribuzione per biblioteca", - cls="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-2", - ), - _provider_bars(dict(provider_counts), total), - cls=_CARD_CLS, - ) - - return Div( - metrics, - provider_panel if total else Div(), - cls="flex flex-col gap-3 mb-5", - id="library-stats-panel", + cls="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6", + id="stats-detail-panel", ) diff --git a/src/studio_ui/routes/library.py b/src/studio_ui/routes/library.py index 2e43cf5..9cb67b7 100644 --- a/src/studio_ui/routes/library.py +++ b/src/studio_ui/routes/library.py @@ -6,7 +6,6 @@ def setup_library_routes(app): """Register library/local-assets routes.""" app.get("/library")(library_handlers.library_page) - app.get("/api/library/stats")(library_handlers.library_stats_panel) app.post("/api/library/delete")(library_handlers.library_delete) app.post("/api/library/cleanup_partial")(library_handlers.library_cleanup_partial) app.post("/api/library/start_download")(library_handlers.library_start_download) diff --git a/src/studio_ui/routes/library_handlers.py b/src/studio_ui/routes/library_handlers.py index 0994ca6..134f518 100644 --- a/src/studio_ui/routes/library_handlers.py +++ b/src/studio_ui/routes/library_handlers.py @@ -10,7 +10,6 @@ from studio_ui.common.toasts import build_toast from studio_ui.components.layout import base_layout from studio_ui.components.library import render_library_card, render_library_page -from studio_ui.components.library_stats import render_library_stats from studio_ui.routes.discovery_helpers import start_downloader_thread from studio_ui.routes.library_query import ( _collect_docs_and_filters, @@ -163,12 +162,6 @@ def library_page( return base_layout("Libreria", content, active_page="library") -def library_stats_panel(): - """Return the lazy-loaded statistics panel for the Library view.""" - manuscripts = VaultManager().get_all_manuscripts() - return render_library_stats(manuscripts) - - def library_delete( doc_id: str, library: str, diff --git a/src/studio_ui/routes/stats.py b/src/studio_ui/routes/stats.py new file mode 100644 index 0000000..f4775c1 --- /dev/null +++ b/src/studio_ui/routes/stats.py @@ -0,0 +1,10 @@ +"""Statistics routes registration.""" + +from studio_ui.routes import stats_handlers + + +def setup_stats_routes(app): + """Register statistics page and API routes.""" + app.get("/stats")(stats_handlers.stats_page) + app.get("/api/stats/sidebar")(stats_handlers.stats_sidebar_widget) + app.get("/api/stats/detail")(stats_handlers.stats_detail_content) diff --git a/src/studio_ui/routes/stats_handlers.py b/src/studio_ui/routes/stats_handlers.py new file mode 100644 index 0000000..ab09faf --- /dev/null +++ b/src/studio_ui/routes/stats_handlers.py @@ -0,0 +1,34 @@ +"""Route handlers for the Statistics page and sidebar widget.""" + +from __future__ import annotations + +from fasthtml.common import Request + +from studio_ui.components.layout import base_layout +from studio_ui.components.library_stats import ( + render_library_stats, + render_sidebar_stats_widget, + render_stats_page_content, +) +from universal_iiif_core.services.storage.vault_manager import VaultManager + + +def stats_page(request: Request): + """Render the full Statistics page.""" + manuscripts = VaultManager().get_all_manuscripts() + content = render_stats_page_content(manuscripts) + if request.headers.get("HX-Request") == "true": + return content + return base_layout("Statistiche", content, active_page="stats") + + +def stats_sidebar_widget(): + """Return the compact nerd-stats widget for the sidebar footer (DB-only).""" + manuscripts = VaultManager().get_all_manuscripts() + return render_sidebar_stats_widget(manuscripts) + + +def stats_detail_content(): + """Return the lazy-loaded detail metrics panel (disk + transcription scan).""" + manuscripts = VaultManager().get_all_manuscripts() + return render_library_stats(manuscripts) From 332cb46ff47b2b6a54fea93a0933ce8005728461 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Sat, 18 Apr 2026 19:47:13 +0200 Subject: [PATCH 3/4] fix(stats): address Copilot review comments on PR #158 - _time_ago: treat naive SQLite timestamps as UTC (not local time) - _dir_size: handle OSError per-file so partial failures don't zero total - _scan_disk_usage: guard local_path against paths outside downloads dir - stats_detail_content: add 5-min in-memory TTL cache to avoid repeated scans - tests: add 33 unit tests covering helpers, components, and route handlers Co-Authored-By: Claude Sonnet 4.6 --- src/studio_ui/components/library_stats.py | 18 +- src/studio_ui/routes/stats_handlers.py | 18 +- tests/test_stats_components.py | 387 ++++++++++++++++++++++ 3 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 tests/test_stats_components.py diff --git a/src/studio_ui/components/library_stats.py b/src/studio_ui/components/library_stats.py index 673b13b..7d66980 100644 --- a/src/studio_ui/components/library_stats.py +++ b/src/studio_ui/components/library_stats.py @@ -10,6 +10,7 @@ from fasthtml.common import H2, A, Div, Li, P, Span, Ul +from universal_iiif_core.config_manager import get_config_manager from universal_iiif_core.logger import get_logger logger = get_logger(__name__) @@ -54,7 +55,10 @@ def _time_ago(ts_str: str | None) -> str: return "โ€”" try: dt = datetime.fromisoformat(str(ts_str).replace("Z", "+00:00")) - delta = datetime.now(timezone.utc) - dt.astimezone(timezone.utc) + # SQLite stores timestamps without tzinfo; treat naive values as UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + delta = datetime.now(timezone.utc) - dt days = delta.days if days == 0: hours = delta.seconds // 3600 @@ -108,13 +112,16 @@ def _scan_transcriptions(manuscripts: list[dict]) -> tuple[int, int]: def _dir_size(path: Path) -> int: total = 0 - with suppress(OSError): - total = sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + for f in path.rglob("*"): + with suppress(OSError): + if f.is_file(): + total += f.stat().st_size return total def _scan_disk_usage(manuscripts: list[dict]) -> int: """Return total bytes used across all local manuscript directories.""" + downloads_root = get_config_manager().get_downloads_dir().resolve() total = 0 seen: set[str] = set() for m in manuscripts: @@ -122,7 +129,10 @@ def _scan_disk_usage(manuscripts: list[dict]) -> int: if not lp or lp in seen: continue seen.add(lp) - p = Path(lp) + p = Path(lp).resolve() + if not p.is_relative_to(downloads_root): + logger.warning("Skipping local_path outside downloads dir: %s", lp) + continue if p.exists(): total += _dir_size(p) return total diff --git a/src/studio_ui/routes/stats_handlers.py b/src/studio_ui/routes/stats_handlers.py index ab09faf..3a8645c 100644 --- a/src/studio_ui/routes/stats_handlers.py +++ b/src/studio_ui/routes/stats_handlers.py @@ -2,6 +2,8 @@ from __future__ import annotations +import time as _time + from fasthtml.common import Request from studio_ui.components.layout import base_layout @@ -12,6 +14,9 @@ ) from universal_iiif_core.services.storage.vault_manager import VaultManager +_detail_cache: tuple[float, object] | None = None +_DETAIL_TTL = 300.0 # seconds + def stats_page(request: Request): """Render the full Statistics page.""" @@ -29,6 +34,15 @@ def stats_sidebar_widget(): def stats_detail_content(): - """Return the lazy-loaded detail metrics panel (disk + transcription scan).""" + """Return the lazy-loaded detail metrics panel (disk + transcription scan). + + Result is cached for 5 minutes to avoid repeated full-disk scans on reload. + """ + global _detail_cache + now = _time.monotonic() + if _detail_cache is not None and now - _detail_cache[0] < _DETAIL_TTL: + return _detail_cache[1] manuscripts = VaultManager().get_all_manuscripts() - return render_library_stats(manuscripts) + result = render_library_stats(manuscripts) + _detail_cache = (now, result) + return result diff --git a/tests/test_stats_components.py b/tests/test_stats_components.py new file mode 100644 index 0000000..f98ed21 --- /dev/null +++ b/tests/test_stats_components.py @@ -0,0 +1,387 @@ +"""Tests for library stats components and route handlers.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from studio_ui.components.library_stats import ( + _dir_size, + _fmt_count, + _format_bytes, + _pct_str, + _scan_disk_usage, + _scan_transcriptions, + _time_ago, + render_library_stats, + render_sidebar_stats_widget, + render_stats_page_content, +) + +# โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def _ms(**overrides) -> dict: + doc = { + "id": "DOC1", + "library": "Gallica", + "display_title": "Test ms", + "asset_state": "complete", + "total_canvases": 10, + "downloaded_canvases": 10, + "local_path": None, + "updated_at": None, + } + doc.update(overrides) + return doc + + +# โ”€โ”€ _format_bytes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_format_bytes_bytes(): + assert _format_bytes(512) == "512.0 B" + + +def test_format_bytes_kilobytes(): + assert _format_bytes(1536) == "1.5 KB" + + +def test_format_bytes_megabytes(): + assert _format_bytes(1_572_864) == "1.5 MB" + + +def test_format_bytes_gigabytes(): + assert _format_bytes(1_610_612_736) == "1.5 GB" + + +# โ”€โ”€ _pct_str โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_pct_str_zero_total(): + assert _pct_str(0, 0) == "โ€”" + + +def test_pct_str_half(): + assert _pct_str(50, 100) == "50%" + + +# โ”€โ”€ _fmt_count โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_fmt_count_small(): + assert _fmt_count(42) == "42" + + +def test_fmt_count_thousands(): + assert _fmt_count(8400) == "8.4k" + + +def test_fmt_count_millions(): + assert _fmt_count(2_500_000) == "2.5M" + + +# โ”€โ”€ _time_ago โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_time_ago_none(): + assert _time_ago(None) == "โ€”" + + +def test_time_ago_naive_sqlite_timestamp(): + """Naive SQLite timestamps (no tz) must be treated as UTC, not local time.""" + result = _time_ago("2000-01-01 00:00:00") + assert result != "โ€”" + assert "a fa" in result + + +def test_time_ago_iso_with_z(): + result = _time_ago("2000-06-15T12:00:00Z") + assert "a fa" in result + + +def test_time_ago_invalid(): + assert _time_ago("not-a-date") == "โ€”" + + +# โ”€โ”€ _dir_size โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_dir_size_counts_files(tmp_path): + (tmp_path / "a.txt").write_bytes(b"hello") + (tmp_path / "b.txt").write_bytes(b"world!") + assert _dir_size(tmp_path) == 11 + + +def test_dir_size_skips_unreadable_file(tmp_path): + """A single unreadable file must not zero out the whole directory size.""" + good = tmp_path / "good.txt" + good.write_bytes(b"abc") + + bad = tmp_path / "bad.txt" + bad.write_bytes(b"xyz") + + original_stat = Path.stat + + def patched_stat(self, *args, **kwargs): + result = original_stat(self, *args, **kwargs) + if self.name == "bad.txt": + raise OSError("permission denied") + return result + + with patch.object(Path, "stat", patched_stat): + size = _dir_size(tmp_path) + + assert size == 3 # only 'good.txt' counted + + +# โ”€โ”€ _scan_disk_usage โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_scan_disk_usage_skips_path_outside_downloads(tmp_path): + downloads = tmp_path / "downloads" + downloads.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (outside / "file.bin").write_bytes(b"secret") + + manuscripts = [_ms(local_path=str(outside))] + + with patch("studio_ui.components.library_stats.get_config_manager") as mock_cm: + mock_cm.return_value.get_downloads_dir.return_value = downloads + total = _scan_disk_usage(manuscripts) + + assert total == 0 + + +def test_scan_disk_usage_counts_valid_path(tmp_path): + downloads = tmp_path / "downloads" + ms_dir = downloads / "Gallica" / "DOC1" + ms_dir.mkdir(parents=True) + (ms_dir / "page.jpg").write_bytes(b"x" * 100) + + manuscripts = [_ms(local_path=str(ms_dir))] + + with patch("studio_ui.components.library_stats.get_config_manager") as mock_cm: + mock_cm.return_value.get_downloads_dir.return_value = downloads + total = _scan_disk_usage(manuscripts) + + assert total == 100 + + +def test_scan_disk_usage_deduplicates_local_path(tmp_path): + downloads = tmp_path / "downloads" + ms_dir = downloads / "lib" / "doc" + ms_dir.mkdir(parents=True) + (ms_dir / "f.jpg").write_bytes(b"x" * 50) + + manuscripts = [_ms(local_path=str(ms_dir)), _ms(local_path=str(ms_dir))] + + with patch("studio_ui.components.library_stats.get_config_manager") as mock_cm: + mock_cm.return_value.get_downloads_dir.return_value = downloads + total = _scan_disk_usage(manuscripts) + + assert total == 50 + + +# โ”€โ”€ _scan_transcriptions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_scan_transcriptions_counts_pages(tmp_path): + ms_dir = tmp_path / "ms1" + data_dir = ms_dir / "data" + data_dir.mkdir(parents=True) + tx = { + "pages": [ + {"full_text": "Hello", "is_manual": True}, + {"full_text": "World", "is_manual": False}, + {"full_text": ""}, + ] + } + (data_dir / "transcription.json").write_text(json.dumps(tx), encoding="utf-8") + + manuscripts = [_ms(local_path=str(ms_dir))] + transcribed, ocr = _scan_transcriptions(manuscripts) + + assert transcribed == 2 + assert ocr == 1 + + +def test_scan_transcriptions_missing_file(tmp_path): + manuscripts = [_ms(local_path=str(tmp_path))] + transcribed, ocr = _scan_transcriptions(manuscripts) + assert transcribed == 0 + assert ocr == 0 + + +# โ”€โ”€ render_sidebar_stats_widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_sidebar_widget_renders_counts(): + manuscripts = [_ms(total_canvases=100, downloaded_canvases=50)] * 3 + rendered = repr(render_sidebar_stats_widget(manuscripts)) + assert "3 mss" in rendered + assert "50%" in rendered + + +def test_sidebar_widget_links_to_stats(): + rendered = repr(render_sidebar_stats_widget([_ms()])) + assert '"/stats"' in rendered or "href='/stats'" in rendered or "/stats" in rendered + + +def test_sidebar_widget_empty_library(): + rendered = repr(render_sidebar_stats_widget([])) + assert "0 mss" in rendered + assert "0% locale" in rendered + + +# โ”€โ”€ render_stats_page_content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_stats_page_content_shows_manuscript_count(): + manuscripts = [_ms(library="BnF")] * 5 + rendered = repr(render_stats_page_content(manuscripts)) + assert "5" in rendered + assert "Manoscritti" in rendered + + +def test_stats_page_content_has_provider_panel(): + manuscripts = [_ms(library="Gallica")] * 3 + [_ms(library="BnF")] * 2 + rendered = repr(render_stats_page_content(manuscripts)) + assert "Gallica" in rendered + assert "BnF" in rendered + assert "Distribuzione per biblioteca" in rendered + + +def test_stats_page_content_has_lazy_detail_placeholder(): + rendered = repr(render_stats_page_content([_ms()])) + assert "/api/stats/detail" in rendered + assert "stats-detail-panel" in rendered + + +def test_stats_page_content_shows_recent_activity(): + manuscripts = [_ms(display_title="Codex A", asset_state="complete", updated_at="2024-01-01T00:00:00Z")] + rendered = repr(render_stats_page_content(manuscripts)) + assert "Ultimi aggiornati" in rendered + assert "Codex A" in rendered + + +# โ”€โ”€ render_library_stats (detail panel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_render_library_stats_returns_detail_panel(tmp_path): + downloads = tmp_path / "downloads" + downloads.mkdir() + + manuscripts = [_ms(total_canvases=20)] + + with patch("studio_ui.components.library_stats.get_config_manager") as mock_cm: + mock_cm.return_value.get_downloads_dir.return_value = downloads + rendered = repr(render_library_stats(manuscripts)) + + assert "stats-detail-panel" in rendered + assert "Spazio disco" in rendered + assert "Pagine trascritte" in rendered + assert "Pagine OCR" in rendered + + +# โ”€โ”€ route handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_stats_page_handler_returns_full_layout_for_normal_request(monkeypatch): + from studio_ui.routes import stats_handlers + + monkeypatch.setattr( + stats_handlers.VaultManager, + "get_all_manuscripts", + lambda self: [_ms()], + ) + + mock_request = MagicMock() + mock_request.headers.get.return_value = None + + result = repr(stats_handlers.stats_page(mock_request)) + assert "Statistiche" in result + + +def test_stats_page_handler_returns_fragment_for_htmx(monkeypatch): + from studio_ui.routes import stats_handlers + + monkeypatch.setattr( + stats_handlers.VaultManager, + "get_all_manuscripts", + lambda self: [_ms()], + ) + + mock_request = MagicMock() + mock_request.headers.get.return_value = "true" + + result = repr(stats_handlers.stats_page(mock_request)) + assert "stats-page" in result + + +def test_stats_sidebar_widget_handler(monkeypatch): + from studio_ui.routes import stats_handlers + + monkeypatch.setattr( + stats_handlers.VaultManager, + "get_all_manuscripts", + lambda self: [_ms(total_canvases=50, downloaded_canvases=25)], + ) + + result = repr(stats_handlers.stats_sidebar_widget()) + assert "sidebar-stats-widget" in result + assert "1 mss" in result + + +def test_stats_detail_handler_uses_cache(monkeypatch, tmp_path): + from studio_ui.routes import stats_handlers + + downloads = tmp_path / "downloads" + downloads.mkdir() + + call_count = {"n": 0} + + def _fake_get_all(self): + call_count["n"] += 1 + return [_ms()] + + monkeypatch.setattr(stats_handlers.VaultManager, "get_all_manuscripts", _fake_get_all) + monkeypatch.setattr( + "studio_ui.components.library_stats.get_config_manager", + lambda: MagicMock(get_downloads_dir=lambda: downloads), + ) + + # Reset cache + stats_handlers._detail_cache = None + + stats_handlers.stats_detail_content() + stats_handlers.stats_detail_content() + + assert call_count["n"] == 1, "Second call should use cache, not re-query VaultManager" + + +def test_stats_detail_handler_refreshes_after_ttl(monkeypatch, tmp_path): + from studio_ui.routes import stats_handlers + + downloads = tmp_path / "downloads" + downloads.mkdir() + + call_count = {"n": 0} + + def _fake_get_all(self): + call_count["n"] += 1 + return [_ms()] + + monkeypatch.setattr(stats_handlers.VaultManager, "get_all_manuscripts", _fake_get_all) + monkeypatch.setattr( + "studio_ui.components.library_stats.get_config_manager", + lambda: MagicMock(get_downloads_dir=lambda: downloads), + ) + + # Seed cache with an expired entry + stats_handlers._detail_cache = (0.0, None) + + stats_handlers.stats_detail_content() + assert call_count["n"] == 1, "Expired cache must trigger a fresh scan" From 3af021733fcd7d00861edd112488008ab3fd5b4e Mon Sep 17 00:00:00 2001 From: nikazzio Date: Sat, 18 Apr 2026 20:32:51 +0200 Subject: [PATCH 4/4] fix(tests): use relative monotonic offset for expired-cache TTL test On fresh CI containers the monotonic clock can be < 300 s, making timestamp 0.0 appear within the TTL window and the cache valid. Seed with (now - TTL - 1) instead to guarantee expiry regardless of uptime. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_stats_components.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_stats_components.py b/tests/test_stats_components.py index f98ed21..a9fd889 100644 --- a/tests/test_stats_components.py +++ b/tests/test_stats_components.py @@ -380,8 +380,10 @@ def _fake_get_all(self): lambda: MagicMock(get_downloads_dir=lambda: downloads), ) - # Seed cache with an expired entry - stats_handlers._detail_cache = (0.0, None) + import time as _t + + # Seed cache with a timestamp guaranteed to be past the TTL + stats_handlers._detail_cache = (_t.monotonic() - stats_handlers._DETAIL_TTL - 1, None) stats_handlers.stats_detail_content() assert call_count["n"] == 1, "Expired cache must trigger a fresh scan"