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:
@@ -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 */}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user