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

@@ -1,10 +1,19 @@
use axum::{extract::State, Json}; use axum::{
use serde::Serialize; extract::{Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row; use sqlx::Row;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Deserialize, IntoParams)]
pub struct StatsQuery {
/// Granularity: "day", "week" or "month" (default: "month")
pub period: Option<String>,
}
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
pub struct StatsOverview { pub struct StatsOverview {
pub total_books: i64, pub total_books: i64,
@@ -117,6 +126,7 @@ pub struct StatsResponse {
get, get,
path = "/stats", path = "/stats",
tag = "stats", tag = "stats",
params(StatsQuery),
responses( responses(
(status = 200, body = StatsResponse), (status = 200, body = StatsResponse),
(status = 401, description = "Unauthorized"), (status = 401, description = "Unauthorized"),
@@ -125,7 +135,9 @@ pub struct StatsResponse {
)] )]
pub async fn get_stats( pub async fn get_stats(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<StatsQuery>,
) -> Result<Json<StatsResponse>, ApiError> { ) -> Result<Json<StatsResponse>, ApiError> {
let period = query.period.as_deref().unwrap_or("month");
// Overview + reading status in one query // Overview + reading status in one query
let overview_row = sqlx::query( let overview_row = sqlx::query(
r#" r#"
@@ -285,20 +297,74 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Additions over time (last 12 months) // Additions over time (with gap filling)
let additions_rows = sqlx::query( let additions_rows = match period {
"day" => {
sqlx::query(
r#" r#"
SELECT SELECT
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month, TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COUNT(*) AS books_added COALESCE(cnt.books_added, 0) AS books_added
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT created_at::date AS dt, COUNT(*) AS books_added
FROM books FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY DATE_TRUNC('month', created_at) GROUP BY created_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_added, 0) AS books_added
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', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_added, 0) AS books_added
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', created_at) AS dt, COUNT(*) AS books_added
FROM books
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', created_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let additions_over_time: Vec<MonthlyAdditions> = additions_rows let additions_over_time: Vec<MonthlyAdditions> = additions_rows
.iter() .iter()
@@ -409,21 +475,77 @@ pub async fn get_stats(
}) })
.collect(); .collect();
// Reading activity over time (last 12 months) // Reading activity over time (with gap filling)
let reading_time_rows = sqlx::query( let reading_time_rows = match period {
"day" => {
sqlx::query(
r#" r#"
SELECT SELECT
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month, TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COUNT(*) AS books_read COALESCE(cnt.books_read, 0) AS books_read
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
LEFT JOIN (
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp FROM book_reading_progress brp
WHERE brp.status = 'read' WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
GROUP BY DATE_TRUNC('month', brp.last_read_at) GROUP BY brp.last_read_at::date
) cnt ON cnt.dt = d.dt
ORDER BY month ASC ORDER BY month ASC
"#, "#,
) )
.fetch_all(&state.pool) .fetch_all(&state.pool)
.await?; .await?
}
"week" => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
COALESCE(cnt.books_read, 0) AS books_read
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', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
GROUP BY DATE_TRUNC('week', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
_ => {
sqlx::query(
r#"
SELECT
TO_CHAR(d.dt, 'YYYY-MM') AS month,
COALESCE(cnt.books_read, 0) AS books_read
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', brp.last_read_at) AS dt, COUNT(*) AS books_read
FROM book_reading_progress brp
WHERE brp.status = 'read'
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
GROUP BY DATE_TRUNC('month', brp.last_read_at)
) cnt ON cnt.dt = d.dt
ORDER BY month ASC
"#,
)
.fetch_all(&state.pool)
.await?
}
};
let reading_over_time: Vec<MonthlyReading> = reading_time_rows let reading_over_time: Vec<MonthlyReading> = reading_time_rows
.iter() .iter()

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 { 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 } from "./components/DashboardCharts";
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";
import { getServerTranslations } from "../lib/i18n/server"; 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"); 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) // 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 }) { 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; 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(); const { t, locale } = await getServerTranslations();
let stats: StatsResponse | null = null; let stats: StatsResponse | null = null;
try { try {
stats = await fetchStats(); stats = await fetchStats(period);
} catch (e) { } catch (e) {
console.error("Failed to fetch stats:", e); console.error("Failed to fetch stats:", e);
} }
@@ -175,20 +201,19 @@ export default async function DashboardPage() {
)} )}
{/* Reading activity line chart */} {/* Reading activity line chart */}
{reading_over_time.length > 0 && (
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle> <CardTitle className="text-base">{t("dashboard.readingActivity")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} noDataLabel={noDataLabel}
data={reading_over_time.map((m) => ({ label: m.month.slice(5), value: m.books_read }))} data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
color="hsl(142 60% 45%)" color="hsl(142 60% 45%)"
/> />
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Charts row */} {/* Charts row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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> </Card>
</div> </div>
{/* Monthly additions line chart full width */} {/* Additions line chart full width */}
<Card hover={false}> <Card hover={false}>
<CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle> <CardTitle className="text-base">{t("dashboard.booksAdded")}</CardTitle>
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RcAreaChart <RcAreaChart
noDataLabel={noDataLabel} 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%)" color="hsl(198 78% 37%)"
/> />
</CardContent> </CardContent>

View File

@@ -584,8 +584,9 @@ export type StatsResponse = {
metadata: MetadataStats; metadata: MetadataStats;
}; };
export async function fetchStats() { export async function fetchStats(period?: "day" | "week" | "month") {
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } }); const params = period && period !== "month" ? `?period=${period}` : "";
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -70,7 +70,10 @@ const en: Record<TranslationKey, string> = {
"dashboard.readingStatus": "Reading status", "dashboard.readingStatus": "Reading status",
"dashboard.byFormat": "By format", "dashboard.byFormat": "By format",
"dashboard.byLibrary": "By library", "dashboard.byLibrary": "By library",
"dashboard.booksAdded": "Books added (last 12 months)", "dashboard.booksAdded": "Books added",
"dashboard.periodDay": "Day",
"dashboard.periodWeek": "Week",
"dashboard.periodMonth": "Month",
"dashboard.popularSeries": "Popular series", "dashboard.popularSeries": "Popular series",
"dashboard.noSeries": "No series yet", "dashboard.noSeries": "No series yet",
"dashboard.unknown": "Unknown", "dashboard.unknown": "Unknown",
@@ -84,7 +87,7 @@ const en: Record<TranslationKey, string> = {
"dashboard.withIsbn": "With ISBN", "dashboard.withIsbn": "With ISBN",
"dashboard.currentlyReading": "Currently reading", "dashboard.currentlyReading": "Currently reading",
"dashboard.recentlyRead": "Recently read", "dashboard.recentlyRead": "Recently read",
"dashboard.readingActivity": "Reading activity (last 12 months)", "dashboard.readingActivity": "Reading activity",
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "No books in progress", "dashboard.noCurrentlyReading": "No books in progress",
"dashboard.noRecentlyRead": "No books read recently", "dashboard.noRecentlyRead": "No books read recently",

View File

@@ -68,7 +68,10 @@ const fr = {
"dashboard.readingStatus": "Statut de lecture", "dashboard.readingStatus": "Statut de lecture",
"dashboard.byFormat": "Par format", "dashboard.byFormat": "Par format",
"dashboard.byLibrary": "Par bibliothèque", "dashboard.byLibrary": "Par bibliothèque",
"dashboard.booksAdded": "Livres ajoutés (12 derniers mois)", "dashboard.booksAdded": "Livres ajoutés",
"dashboard.periodDay": "Jour",
"dashboard.periodWeek": "Semaine",
"dashboard.periodMonth": "Mois",
"dashboard.popularSeries": "Séries populaires", "dashboard.popularSeries": "Séries populaires",
"dashboard.noSeries": "Aucune série pour le moment", "dashboard.noSeries": "Aucune série pour le moment",
"dashboard.unknown": "Inconnu", "dashboard.unknown": "Inconnu",
@@ -82,7 +85,7 @@ const fr = {
"dashboard.withIsbn": "Avec ISBN", "dashboard.withIsbn": "Avec ISBN",
"dashboard.currentlyReading": "En cours de lecture", "dashboard.currentlyReading": "En cours de lecture",
"dashboard.recentlyRead": "Derniers livres lus", "dashboard.recentlyRead": "Derniers livres lus",
"dashboard.readingActivity": "Activité de lecture (12 derniers mois)", "dashboard.readingActivity": "Activité de lecture",
"dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.pageProgress": "p. {{current}} / {{total}}",
"dashboard.noCurrentlyReading": "Aucun livre en cours", "dashboard.noCurrentlyReading": "Aucun livre en cours",
"dashboard.noRecentlyRead": "Aucun livre lu récemment", "dashboard.noRecentlyRead": "Aucun livre lu récemment",