From 00094b22c6e892bdcb70dd850cbc00ea2f965e8c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 18 Mar 2026 22:19:53 +0100 Subject: [PATCH] feat: add metadata statistics to dashboard Add a new metadata row to the dashboard with three cards: - Series metadata coverage (linked vs unlinked donut) - Provider breakdown (donut by provider) - Book metadata quality (summary and ISBN fill rates) Includes API changes (stats.rs), frontend types, and FR/EN translations. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/stats.rs | 63 ++++++++++++++++++++++++++++++++ apps/backoffice/app/page.tsx | 65 +++++++++++++++++++++++++++++++++- apps/backoffice/lib/api.ts | 15 ++++++++ apps/backoffice/lib/i18n/en.ts | 7 ++++ apps/backoffice/lib/i18n/fr.ts | 7 ++++ 5 files changed, 156 insertions(+), 1 deletion(-) diff --git a/apps/api/src/stats.rs b/apps/api/src/stats.rs index 5850e6d..ce3bb6c 100644 --- a/apps/api/src/stats.rs +++ b/apps/api/src/stats.rs @@ -58,6 +58,22 @@ pub struct MonthlyAdditions { pub books_added: i64, } +#[derive(Serialize, ToSchema)] +pub struct MetadataStats { + pub total_series: i64, + pub series_linked: i64, + pub series_unlinked: i64, + pub books_with_summary: i64, + pub books_with_isbn: i64, + pub by_provider: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ProviderCount { + pub provider: String, + pub count: i64, +} + #[derive(Serialize, ToSchema)] pub struct StatsResponse { pub overview: StatsOverview, @@ -67,6 +83,7 @@ pub struct StatsResponse { pub by_library: Vec, pub top_series: Vec, pub additions_over_time: Vec, + pub metadata: MetadataStats, } /// Get collection statistics for the dashboard @@ -265,6 +282,51 @@ pub async fn get_stats( }) .collect(); + // Metadata stats + let meta_row = sqlx::query( + r#" + SELECT + (SELECT COUNT(DISTINCT NULLIF(series, '')) FROM books) AS total_series, + (SELECT COUNT(DISTINCT series_name) FROM external_metadata_links WHERE status = 'approved') AS series_linked, + (SELECT COUNT(*) FROM books WHERE summary IS NOT NULL AND summary != '') AS books_with_summary, + (SELECT COUNT(*) FROM books WHERE isbn IS NOT NULL AND isbn != '') AS books_with_isbn + "#, + ) + .fetch_one(&state.pool) + .await?; + + let meta_total_series: i64 = meta_row.get("total_series"); + let meta_series_linked: i64 = meta_row.get("series_linked"); + + let provider_rows = sqlx::query( + r#" + SELECT provider, COUNT(DISTINCT series_name) AS count + FROM external_metadata_links + WHERE status = 'approved' + GROUP BY provider + ORDER BY count DESC + "#, + ) + .fetch_all(&state.pool) + .await?; + + let by_provider: Vec = provider_rows + .iter() + .map(|r| ProviderCount { + provider: r.get("provider"), + count: r.get("count"), + }) + .collect(); + + let metadata = MetadataStats { + total_series: meta_total_series, + series_linked: meta_series_linked, + series_unlinked: meta_total_series - meta_series_linked, + books_with_summary: meta_row.get("books_with_summary"), + books_with_isbn: meta_row.get("books_with_isbn"), + by_provider, + }; + Ok(Json(StatsResponse { overview, reading_status, @@ -273,5 +335,6 @@ pub async fn get_stats( by_library, top_series, additions_over_time, + metadata, })) } diff --git a/apps/backoffice/app/page.tsx b/apps/backoffice/app/page.tsx index c2d4ccc..ddc68f9 100644 --- a/apps/backoffice/app/page.tsx +++ b/apps/backoffice/app/page.tsx @@ -137,7 +137,7 @@ export default async function DashboardPage() { ); } - const { overview, reading_status, by_format, by_language, by_library, top_series, additions_over_time } = stats; + const { overview, reading_status, by_format, by_language, by_library, top_series, additions_over_time, metadata } = stats; const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"]; const formatColors = [ @@ -231,6 +231,69 @@ export default async function DashboardPage() { + {/* Metadata row */} +
+ {/* Series metadata coverage donut */} + + + {t("dashboard.metadataCoverage")} + + + + + + + {/* By provider donut */} + + + {t("dashboard.byProvider")} + + + ({ + label: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), + value: p.count, + color: formatColors[i % formatColors.length], + }))} + /> + + + + {/* Book metadata quality */} + + + {t("dashboard.bookMetadata")} + + +
+ 0 ? `${Math.round((metadata.books_with_summary / overview.total_books) * 100)}%` : "0%"} + color="hsl(198 78% 37%)" + /> + 0 ? `${Math.round((metadata.books_with_isbn / overview.total_books) * 100)}%` : "0%"} + color="hsl(280 60% 50%)" + /> +
+
+
+
+ {/* Second row */}
{/* Monthly additions bar chart */} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 43d2719..899f7eb 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -494,6 +494,20 @@ export type MonthlyAdditions = { books_added: number; }; +export type ProviderCount = { + provider: string; + count: number; +}; + +export type MetadataStats = { + total_series: number; + series_linked: number; + series_unlinked: number; + books_with_summary: number; + books_with_isbn: number; + by_provider: ProviderCount[]; +}; + export type StatsResponse = { overview: StatsOverview; reading_status: ReadingStatusStats; @@ -502,6 +516,7 @@ export type StatsResponse = { by_library: LibraryStatsItem[]; top_series: TopSeriesItem[]; additions_over_time: MonthlyAdditions[]; + metadata: MetadataStats; }; export async function fetchStats() { diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 2b1ad08..2d48c1f 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -75,6 +75,13 @@ const en: Record = { "dashboard.noSeries": "No series yet", "dashboard.unknown": "Unknown", "dashboard.readCount": "{{read}}/{{total}} read", + "dashboard.metadataCoverage": "Metadata coverage", + "dashboard.seriesLinked": "Linked series", + "dashboard.seriesUnlinked": "Unlinked series", + "dashboard.byProvider": "By provider", + "dashboard.bookMetadata": "Book metadata", + "dashboard.withSummary": "With summary", + "dashboard.withIsbn": "With ISBN", // Books page "books.title": "Books", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index a61ecf9..898c165 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -73,6 +73,13 @@ const fr = { "dashboard.noSeries": "Aucune série pour le moment", "dashboard.unknown": "Inconnu", "dashboard.readCount": "{{read}}/{{total}} lu", + "dashboard.metadataCoverage": "Couverture métadonnées", + "dashboard.seriesLinked": "Séries liées", + "dashboard.seriesUnlinked": "Séries non liées", + "dashboard.byProvider": "Par fournisseur", + "dashboard.bookMetadata": "Métadonnées livres", + "dashboard.withSummary": "Avec résumé", + "dashboard.withIsbn": "Avec ISBN", // Books page "books.title": "Livres",