feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 10:43:45 +01:00
parent 5d33a35407
commit e26219989f
6 changed files with 223 additions and 5 deletions

View File

@@ -106,6 +106,15 @@ pub struct MonthlyReading {
pub books_read: i64, 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)] #[derive(Serialize, ToSchema)]
pub struct StatsResponse { pub struct StatsResponse {
pub overview: StatsOverview, pub overview: StatsOverview,
@@ -118,6 +127,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 jobs_over_time: Vec<JobTimePoint>,
pub metadata: MetadataStats, pub metadata: MetadataStats,
} }
@@ -555,6 +565,125 @@ pub async fn get_stats(
}) })
.collect(); .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<JobTimePoint> = 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 { Ok(Json(StatsResponse {
overview, overview,
reading_status, reading_status,
@@ -566,6 +695,7 @@ pub async fn get_stats(
by_library, by_library,
top_series, top_series,
additions_over_time, additions_over_time,
jobs_over_time,
metadata, metadata,
})) }))
} }

View File

@@ -3,7 +3,7 @@
import { import {
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, PieChart, Pie, Cell, ResponsiveContainer, Tooltip,
BarChart, Bar, XAxis, YAxis, CartesianGrid, BarChart, Bar, XAxis, YAxis, CartesianGrid,
AreaChart, Area, AreaChart, Area, Line, LineChart,
Legend, Legend,
} from "recharts"; } from "recharts";
@@ -186,3 +186,46 @@ export function RcHorizontalBar({
</ResponsiveContainer> </ResponsiveContainer>
); );
} }
// ---------------------------------------------------------------------------
// Multi-line chart (jobs over time)
// ---------------------------------------------------------------------------
export function RcMultiLineChart({
data,
lines,
noDataLabel,
}: {
data: Record<string, unknown>[];
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 <p className="text-muted-foreground text-sm text-center py-8">{noDataLabel}</p>;
return (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={data} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.3} />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "var(--color-muted-foreground)" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{ backgroundColor: "var(--color-card)", border: "1px solid var(--color-border)", borderRadius: 8, fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
{lines.map((l) => (
<Line
key={l.key}
type="monotone"
dataKey={l.key}
name={l.label}
stroke={l.color}
strokeWidth={2}
dot={{ r: 3, fill: l.color }}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api"; import { fetchStats, StatsResponse, getBookCoverUrl } from "../lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "./components/ui"; 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 { PeriodToggle } from "./components/PeriodToggle";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; 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 readingColors = ["hsl(220 13% 70%)", "hsl(45 93% 47%)", "hsl(142 60% 45%)"];
const formatColors = [ const formatColors = [
@@ -136,7 +136,7 @@ export default async function DashboardPage({
{currently_reading.length === 0 ? ( {currently_reading.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p> <p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noCurrentlyReading")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{currently_reading.slice(0, 8).map((book) => { {currently_reading.slice(0, 8).map((book) => {
const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0; const pct = book.page_count > 0 ? Math.round((book.current_page / book.page_count) * 100) : 0;
return ( return (
@@ -176,7 +176,7 @@ export default async function DashboardPage({
{recently_read.length === 0 ? ( {recently_read.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p> <p className="text-muted-foreground text-sm text-center py-4">{t("dashboard.noRecentlyRead")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3 max-h-[216px] overflow-y-auto pr-1">
{recently_read.map((book) => ( {recently_read.map((book) => (
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group"> <Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
<Image <Image
@@ -391,6 +391,32 @@ export default async function DashboardPage({
</CardContent> </CardContent>
</Card> </Card>
{/* Jobs over time multi-line chart */}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.jobsOverTime")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcMultiLineChart
noDataLabel={noDataLabel}
data={jobs_over_time.map((j) => ({
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%)" },
]}
/>
</CardContent>
</Card>
{/* Quick links */} {/* Quick links */}
<QuickLinks t={t} /> <QuickLinks t={t} />
</div> </div>

View File

@@ -570,6 +570,14 @@ export type MonthlyReading = {
books_read: number; books_read: number;
}; };
export type JobTimePoint = {
label: string;
scan: number;
rebuild: number;
thumbnail: number;
other: number;
};
export type StatsResponse = { export type StatsResponse = {
overview: StatsOverview; overview: StatsOverview;
reading_status: ReadingStatusStats; reading_status: ReadingStatusStats;
@@ -581,6 +589,7 @@ export type StatsResponse = {
by_library: LibraryStatsItem[]; by_library: LibraryStatsItem[];
top_series: TopSeriesItem[]; top_series: TopSeriesItem[];
additions_over_time: MonthlyAdditions[]; additions_over_time: MonthlyAdditions[];
jobs_over_time: JobTimePoint[];
metadata: MetadataStats; metadata: MetadataStats;
}; };

View File

@@ -71,6 +71,11 @@ const en: Record<TranslationKey, string> = {
"dashboard.byFormat": "By format", "dashboard.byFormat": "By format",
"dashboard.byLibrary": "By library", "dashboard.byLibrary": "By library",
"dashboard.booksAdded": "Books added", "dashboard.booksAdded": "Books added",
"dashboard.jobsOverTime": "Job runs",
"dashboard.jobScan": "Scan",
"dashboard.jobRebuild": "Rebuild",
"dashboard.jobThumbnail": "Thumbnails",
"dashboard.jobOther": "Other",
"dashboard.periodDay": "Day", "dashboard.periodDay": "Day",
"dashboard.periodWeek": "Week", "dashboard.periodWeek": "Week",
"dashboard.periodMonth": "Month", "dashboard.periodMonth": "Month",

View File

@@ -69,6 +69,11 @@ const fr = {
"dashboard.byFormat": "Par format", "dashboard.byFormat": "Par format",
"dashboard.byLibrary": "Par bibliothèque", "dashboard.byLibrary": "Par bibliothèque",
"dashboard.booksAdded": "Livres ajoutés", "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.periodDay": "Jour",
"dashboard.periodWeek": "Semaine", "dashboard.periodWeek": "Semaine",
"dashboard.periodMonth": "Mois", "dashboard.periodMonth": "Mois",