From e26219989fe84aebdfc1dd98cee77c308c1c6ad5 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 22 Mar 2026 10:43:45 +0100 Subject: [PATCH] feat: add job runs chart and scrollable reading lists on dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add multi-line chart showing job runs over time by type (scan, rebuild, thumbnails, other) with the same day/week/month toggle - Limit currently reading and recently read lists to 3 visible items with a scrollbar for overflow - Fix NUMERIC→BIGINT cast for SUM/COALESCE in jobs SQL queries Co-Authored-By: Claude Opus 4.6 --- apps/api/src/stats.rs | 130 ++++++++++++++++++ .../app/components/DashboardCharts.tsx | 45 +++++- apps/backoffice/app/page.tsx | 34 ++++- apps/backoffice/lib/api.ts | 9 ++ apps/backoffice/lib/i18n/en.ts | 5 + apps/backoffice/lib/i18n/fr.ts | 5 + 6 files changed, 223 insertions(+), 5 deletions(-) diff --git a/apps/api/src/stats.rs b/apps/api/src/stats.rs index 307e4e9..f9d0d76 100644 --- a/apps/api/src/stats.rs +++ b/apps/api/src/stats.rs @@ -106,6 +106,15 @@ pub struct MonthlyReading { pub books_read: i64, } +#[derive(Serialize, ToSchema)] +pub struct JobTimePoint { + pub label: String, + pub scan: i64, + pub rebuild: i64, + pub thumbnail: i64, + pub other: i64, +} + #[derive(Serialize, ToSchema)] pub struct StatsResponse { pub overview: StatsOverview, @@ -118,6 +127,7 @@ pub struct StatsResponse { pub by_library: Vec, pub top_series: Vec, pub additions_over_time: Vec, + pub jobs_over_time: Vec, pub metadata: MetadataStats, } @@ -555,6 +565,125 @@ pub async fn get_stats( }) .collect(); + // Jobs over time (with gap filling, grouped by type category) + let jobs_rows = match period { + "day" => { + sqlx::query( + r#" + SELECT + TO_CHAR(d.dt, 'YYYY-MM-DD') AS label, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other + FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt) + LEFT JOIN ( + SELECT + finished_at::date AS dt, + CASE + WHEN type = 'scan' THEN 'scan' + WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild' + WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail' + ELSE 'other' + END AS cat, + COUNT(*) AS c + FROM index_jobs + WHERE status IN ('success', 'failed') + AND finished_at >= CURRENT_DATE - INTERVAL '6 days' + GROUP BY finished_at::date, cat + ) cnt ON cnt.dt = d.dt + GROUP BY d.dt + ORDER BY label ASC + "#, + ) + .fetch_all(&state.pool) + .await? + } + "week" => { + sqlx::query( + r#" + SELECT + TO_CHAR(d.dt, 'YYYY-MM-DD') AS label, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other + FROM generate_series( + DATE_TRUNC('week', NOW() - INTERVAL '2 months'), + DATE_TRUNC('week', NOW()), + '1 week' + ) AS d(dt) + LEFT JOIN ( + SELECT + DATE_TRUNC('week', finished_at) AS dt, + CASE + WHEN type = 'scan' THEN 'scan' + WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild' + WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail' + ELSE 'other' + END AS cat, + COUNT(*) AS c + FROM index_jobs + WHERE status IN ('success', 'failed') + AND finished_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months') + GROUP BY DATE_TRUNC('week', finished_at), cat + ) cnt ON cnt.dt = d.dt + GROUP BY d.dt + ORDER BY label ASC + "#, + ) + .fetch_all(&state.pool) + .await? + } + _ => { + sqlx::query( + r#" + SELECT + TO_CHAR(d.dt, 'YYYY-MM') AS label, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'scan'), 0)::BIGINT AS scan, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'rebuild'), 0)::BIGINT AS rebuild, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'thumbnail'), 0)::BIGINT AS thumbnail, + COALESCE(SUM(cnt.c) FILTER (WHERE cnt.cat = 'other'), 0)::BIGINT AS other + FROM generate_series( + DATE_TRUNC('month', NOW()) - INTERVAL '11 months', + DATE_TRUNC('month', NOW()), + '1 month' + ) AS d(dt) + LEFT JOIN ( + SELECT + DATE_TRUNC('month', finished_at) AS dt, + CASE + WHEN type = 'scan' THEN 'scan' + WHEN type IN ('rebuild', 'full_rebuild', 'rescan') THEN 'rebuild' + WHEN type IN ('thumbnail_rebuild', 'thumbnail_regenerate') THEN 'thumbnail' + ELSE 'other' + END AS cat, + COUNT(*) AS c + FROM index_jobs + WHERE status IN ('success', 'failed') + AND finished_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' + GROUP BY DATE_TRUNC('month', finished_at), cat + ) cnt ON cnt.dt = d.dt + GROUP BY d.dt + ORDER BY label ASC + "#, + ) + .fetch_all(&state.pool) + .await? + } + }; + + let jobs_over_time: Vec = jobs_rows + .iter() + .map(|r| JobTimePoint { + label: r.get("label"), + scan: r.get("scan"), + rebuild: r.get("rebuild"), + thumbnail: r.get("thumbnail"), + other: r.get("other"), + }) + .collect(); + Ok(Json(StatsResponse { overview, reading_status, @@ -566,6 +695,7 @@ pub async fn get_stats( by_library, top_series, additions_over_time, + jobs_over_time, metadata, })) } diff --git a/apps/backoffice/app/components/DashboardCharts.tsx b/apps/backoffice/app/components/DashboardCharts.tsx index 146aa30..ad444eb 100644 --- a/apps/backoffice/app/components/DashboardCharts.tsx +++ b/apps/backoffice/app/components/DashboardCharts.tsx @@ -3,7 +3,7 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, - AreaChart, Area, + AreaChart, Area, Line, LineChart, Legend, } from "recharts"; @@ -186,3 +186,46 @@ export function RcHorizontalBar({ ); } + +// --------------------------------------------------------------------------- +// Multi-line chart (jobs over time) +// --------------------------------------------------------------------------- + +export function RcMultiLineChart({ + data, + lines, + noDataLabel, +}: { + data: Record[]; + lines: { key: string; label: string; color: string }[]; + noDataLabel?: string; +}) { + const hasData = data.some((d) => lines.some((l) => (d[l.key] as number) > 0)); + if (data.length === 0 || !hasData) + return

{noDataLabel}

; + + return ( + + + + + + + + {lines.map((l) => ( + + ))} + + + ); +} diff --git a/apps/backoffice/app/page.tsx b/apps/backoffice/app/page.tsx index 22f02b7..1f3ee3b 100644 --- a/apps/backoffice/app/page.tsx +++ b/apps/backoffice/app/page.tsx @@ -1,7 +1,7 @@ import React from "react"; import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api"; import { Card, CardContent, CardHeader, CardTitle } from "./components/ui"; -import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar } from "./components/DashboardCharts"; +import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "./components/DashboardCharts"; import { PeriodToggle } from "./components/PeriodToggle"; import Image from "next/image"; import Link from "next/link"; @@ -88,7 +88,7 @@ export default async function DashboardPage({ ); } - const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, metadata } = stats; + const { overview, reading_status, currently_reading = [], recently_read = [], reading_over_time = [], by_format, by_library, top_series, additions_over_time, jobs_over_time = [], metadata } = stats; const readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"]; const formatColors = [ @@ -136,7 +136,7 @@ export default async function DashboardPage({ {currently_reading.length === 0 ? (

{t("dashboard.noCurrentlyReading")}

) : ( -
+
{currently_reading.slice(0, 8).map((book) => { const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0; return ( @@ -176,7 +176,7 @@ export default async function DashboardPage({ {recently_read.length === 0 ? (

{t("dashboard.noRecentlyRead")}

) : ( -
+
{recently_read.map((book) => ( + {/* Jobs over time – multi-line chart */} + + + {t("dashboard.jobsOverTime")} + + + + ({ + label: formatChartLabel(j.label, period, locale), + scan: j.scan, + rebuild: j.rebuild, + thumbnail: j.thumbnail, + other: j.other, + }))} + lines={[ + { key: "scan", label: t("dashboard.jobScan"), color: "hsl(198 78% 37%)" }, + { key: "rebuild", label: t("dashboard.jobRebuild"), color: "hsl(142 60% 45%)" }, + { key: "thumbnail", label: t("dashboard.jobThumbnail"), color: "hsl(45 93% 47%)" }, + { key: "other", label: t("dashboard.jobOther"), color: "hsl(280 60% 50%)" }, + ]} + /> + + + {/* Quick links */}
diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index f7ab444..78b9429 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -570,6 +570,14 @@ export type MonthlyReading = { books_read: number; }; +export type JobTimePoint = { + label: string; + scan: number; + rebuild: number; + thumbnail: number; + other: number; +}; + export type StatsResponse = { overview: StatsOverview; reading_status: ReadingStatusStats; @@ -581,6 +589,7 @@ export type StatsResponse = { by_library: LibraryStatsItem[]; top_series: TopSeriesItem[]; additions_over_time: MonthlyAdditions[]; + jobs_over_time: JobTimePoint[]; metadata: MetadataStats; }; diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 88d2919..5f92568 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -71,6 +71,11 @@ const en: Record = { "dashboard.byFormat": "By format", "dashboard.byLibrary": "By library", "dashboard.booksAdded": "Books added", + "dashboard.jobsOverTime": "Job runs", + "dashboard.jobScan": "Scan", + "dashboard.jobRebuild": "Rebuild", + "dashboard.jobThumbnail": "Thumbnails", + "dashboard.jobOther": "Other", "dashboard.periodDay": "Day", "dashboard.periodWeek": "Week", "dashboard.periodMonth": "Month", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index c94ff80..efcc78e 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -69,6 +69,11 @@ const fr = { "dashboard.byFormat": "Par format", "dashboard.byLibrary": "Par bibliothèque", "dashboard.booksAdded": "Livres ajoutés", + "dashboard.jobsOverTime": "Exécutions de jobs", + "dashboard.jobScan": "Scan", + "dashboard.jobRebuild": "Rebuild", + "dashboard.jobThumbnail": "Thumbnails", + "dashboard.jobOther": "Autre", "dashboard.periodDay": "Jour", "dashboard.periodWeek": "Semaine", "dashboard.periodMonth": "Mois",