feat: add day/week/month period toggle for dashboard line charts

Add a period selector (day, week, month) to the reading activity and
books added charts. The API now accepts a ?period= query param and
returns gap-filled data using generate_series so all time slots appear
even with zero values. Labels are locale-aware (short month, weekday).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 10:27:24 +01:00
parent 6f663eaee7
commit cf1953d11f
6 changed files with 259 additions and 57 deletions

View File

@@ -0,0 +1,47 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
type Period = "day" | "week" | "month";
export function PeriodToggle({
labels,
}: {
labels: { day: string; week: string; month: string };
}) {
const router = useRouter();
const searchParams = useSearchParams();
const raw = searchParams.get("period");
const current: Period = raw === "day" ? "day" : raw === "week" ? "week" : "month";
function setPeriod(period: Period) {
const params = new URLSearchParams(searchParams.toString());
if (period === "month") {
params.delete("period");
} else {
params.set("period", period);
}
const qs = params.toString();
router.push(qs ? `?${qs}` : "/", { scroll: false });
}
const options: Period[] = ["day", "week", "month"];
return (
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
{options.map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
current === p
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{labels[p]}
</button>
))}
</div>
);
}

View File

@@ -2,6 +2,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 { PeriodToggle } from "./components/PeriodToggle";
import Image from "next/image";
import Link from "next/link";
import { getServerTranslations } from "../lib/i18n/server";
@@ -21,6 +22,24 @@ function formatNumber(n: number, locale: string): string {
return n.toLocaleString(locale === "fr" ? "fr-FR" : "en-US");
}
function formatChartLabel(raw: string, period: "day" | "week" | "month", locale: string): string {
const loc = locale === "fr" ? "fr-FR" : "en-US";
if (period === "month") {
// raw = "YYYY-MM"
const [y, m] = raw.split("-");
const d = new Date(Number(y), Number(m) - 1, 1);
return d.toLocaleDateString(loc, { month: "short" });
}
if (period === "week") {
// raw = "YYYY-MM-DD" (Monday of the week)
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { day: "numeric", month: "short" });
}
// day: raw = "YYYY-MM-DD"
const d = new Date(raw + "T00:00:00");
return d.toLocaleDateString(loc, { weekday: "short", day: "numeric" });
}
// Horizontal progress bar for metadata quality (stays server-rendered, no recharts needed)
function HorizontalBar({ label, value, max, subLabel, color = "var(--color-primary)" }: { label: string; value: number; max: number; subLabel?: string; color?: string }) {
const pct = max > 0 ? (value / max) * 100 : 0;
@@ -40,12 +59,19 @@ function HorizontalBar({ label, value, max, subLabel, color = "var(--color-prima
);
}
export default async function DashboardPage() {
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParamsAwaited = await searchParams;
const rawPeriod = searchParamsAwaited.period;
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null;
try {
stats = await fetchStats();
stats = await fetchStats(period);
} catch (e) {
console.error("Failed to fetch stats:", e);
}
@@ -175,20 +201,19 @@ export default async function DashboardPage() {
)}
{/* Reading activity line chart */}
{reading_over_time.length > 0 && (
<Card hover={false}>
<CardHeader>
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))}
color="hsl(142 60% 45%)"
/>
</CardContent>
</Card>
)}
<Card hover={false}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)"
/>
</CardContent>
</Card>
{/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -351,15 +376,16 @@ export default async function DashboardPage() {
</Card>
</div>
{/* Monthly additions line chart full width */}
{/* Additions line chart full width */}
<Card hover={false}>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader>
<CardContent>
<RcAreaChart
noDataLabel={noDataLabel}
data={additions_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_added }))}
data={additions_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_added }))}
color="hsl(198 78% 37%)"
/>
</CardContent>