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:
@@ -58,6 +58,22 @@ pub struct MonthlyAdditions {
|
|||||||
pub books_added: i64,
|
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)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct StatsResponse {
|
pub struct StatsResponse {
|
||||||
pub overview: StatsOverview,
|
pub overview: StatsOverview,
|
||||||
@@ -67,6 +83,7 @@ pub struct StatsResponse {
|
|||||||
pub by_library: Vec<LibraryStats>,
|
pub by_library: Vec<LibraryStats>,
|
||||||
pub top_series: Vec<TopSeries>,
|
pub top_series: Vec<TopSeries>,
|
||||||
pub additions_over_time: Vec<MonthlyAdditions>,
|
pub additions_over_time: Vec<MonthlyAdditions>,
|
||||||
|
pub metadata: MetadataStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get collection statistics for the dashboard
|
/// Get collection statistics for the dashboard
|
||||||
@@ -265,6 +282,51 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
Ok(Json(StatsResponse {
|
||||||
overview,
|
overview,
|
||||||
reading_status,
|
reading_status,
|
||||||
@@ -273,5 +335,6 @@ pub async fn get_stats(
|
|||||||
by_library,
|
by_library,
|
||||||
top_series,
|
top_series,
|
||||||
additions_over_time,
|
additions_over_time,
|
||||||
|
metadata,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
|
||||||
const formatColors = [
|
const formatColors = [
|
||||||
@@ -231,6 +231,69 @@ export default async function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Second row */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Monthly additions bar chart */}
|
{/* Monthly additions bar chart */}
|
||||||
|
|||||||
@@ -494,6 +494,20 @@ export type MonthlyAdditions = {
|
|||||||
books_added: number;
|
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 = {
|
export type StatsResponse = {
|
||||||
overview: StatsOverview;
|
overview: StatsOverview;
|
||||||
reading_status: ReadingStatusStats;
|
reading_status: ReadingStatusStats;
|
||||||
@@ -502,6 +516,7 @@ export type StatsResponse = {
|
|||||||
by_library: LibraryStatsItem[];
|
by_library: LibraryStatsItem[];
|
||||||
top_series: TopSeriesItem[];
|
top_series: TopSeriesItem[];
|
||||||
additions_over_time: MonthlyAdditions[];
|
additions_over_time: MonthlyAdditions[];
|
||||||
|
metadata: MetadataStats;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchStats() {
|
export async function fetchStats() {
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.noSeries": "No series yet",
|
"dashboard.noSeries": "No series yet",
|
||||||
"dashboard.unknown": "Unknown",
|
"dashboard.unknown": "Unknown",
|
||||||
"dashboard.readCount": "{{read}}/{{total}} read",
|
"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 page
|
||||||
"books.title": "Books",
|
"books.title": "Books",
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ const fr = {
|
|||||||
"dashboard.noSeries": "Aucune série pour le moment",
|
"dashboard.noSeries": "Aucune série pour le moment",
|
||||||
"dashboard.unknown": "Inconnu",
|
"dashboard.unknown": "Inconnu",
|
||||||
"dashboard.readCount": "{{read}}/{{total}} lu",
|
"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 page
|
||||||
"books.title": "Livres",
|
"books.title": "Livres",
|
||||||
|
|||||||
Reference in New Issue
Block a user