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 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:19:53 +01:00
parent 1e4d9acebe
commit 00094b22c6
5 changed files with 156 additions and 1 deletions

View File

@@ -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<ProviderCount>,
}
#[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<LibraryStats>,
pub top_series: Vec<TopSeries>,
pub additions_over_time: Vec<MonthlyAdditions>,
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<ProviderCount> = 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,
}))
}

View File

@@ -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() {
</Card>
</div>
{/* Metadata row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Series metadata coverage donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.metadataCoverage")}</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
locale={locale}
noDataLabel={noDataLabel}
data={[
{ label: t("dashboard.seriesLinked"), value: metadata.series_linked, color: "hsl(142 60% 45%)" },
{ label: t("dashboard.seriesUnlinked"), value: metadata.series_unlinked, color: "hsl(220 13% 70%)" },
]}
/>
</CardContent>
</Card>
{/* By provider donut */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.byProvider")}</CardTitle>
</CardHeader>
<CardContent>
<DonutChart
locale={locale}
noDataLabel={noDataLabel}
data={metadata.by_provider.map((p, i) => ({
label: p.provider.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
value: p.count,
color: formatColors[i % formatColors.length],
}))}
/>
</CardContent>
</Card>
{/* Book metadata quality */}
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.bookMetadata")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<HorizontalBar
label={t("dashboard.withSummary")}
value={metadata.books_with_summary}
max={overview.total_books}
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_summary / overview.total_books) * 100)}%` : "0%"}
color="hsl(198 78% 37%)"
/>
<HorizontalBar
label={t("dashboard.withIsbn")}
value={metadata.books_with_isbn}
max={overview.total_books}
subLabel={overview.total_books > 0 ? `${Math.round((metadata.books_with_isbn / overview.total_books) * 100)}%` : "0%"}
color="hsl(280 60% 50%)"
/>
</div>
</CardContent>
</Card>
</div>
{/* Second row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Monthly additions bar chart */}

View File

@@ -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() {

View File

@@ -75,6 +75,13 @@ const en: Record<TranslationKey, string> = {
"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",

View File

@@ -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",