feat: add job runs chart and scrollable reading lists on dashboard
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m5s
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:
@@ -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({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<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) => {
|
||||
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 ? (
|
||||
<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) => (
|
||||
<Link key={book.book_id} href={`/books/${book.book_id}` as any} className="flex items-center gap-3 group">
|
||||
<Image
|
||||
@@ -391,6 +391,32 @@ export default async function DashboardPage({
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<QuickLinks t={t} />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ const en: Record<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user