From cf1953d11f047214f3cb71f57b520e60c054f82d Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 22 Mar 2026 10:27:24 +0100 Subject: [PATCH] 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 --- apps/api/src/stats.rs | 186 +++++++++++++++--- .../app/components/PeriodToggle.tsx | 47 +++++ apps/backoffice/app/page.tsx | 64 ++++-- apps/backoffice/lib/api.ts | 5 +- apps/backoffice/lib/i18n/en.ts | 7 +- apps/backoffice/lib/i18n/fr.ts | 7 +- 6 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 apps/backoffice/app/components/PeriodToggle.tsx diff --git a/apps/api/src/stats.rs b/apps/api/src/stats.rs index 019b6ba..307e4e9 100644 --- a/apps/api/src/stats.rs +++ b/apps/api/src/stats.rs @@ -1,10 +1,19 @@ -use axum::{extract::State, Json}; -use serde::Serialize; +use axum::{ + extract::{Query, State}, + Json, +}; +use serde::{Deserialize, Serialize}; use sqlx::Row; -use utoipa::ToSchema; +use utoipa::{IntoParams, ToSchema}; use crate::{error::ApiError, state::AppState}; +#[derive(Deserialize, IntoParams)] +pub struct StatsQuery { + /// Granularity: "day", "week" or "month" (default: "month") + pub period: Option, +} + #[derive(Serialize, ToSchema)] pub struct StatsOverview { pub total_books: i64, @@ -117,6 +126,7 @@ pub struct StatsResponse { get, path = "/stats", tag = "stats", + params(StatsQuery), responses( (status = 200, body = StatsResponse), (status = 401, description = "Unauthorized"), @@ -125,7 +135,9 @@ pub struct StatsResponse { )] pub async fn get_stats( State(state): State, + Query(query): Query, ) -> Result, ApiError> { + let period = query.period.as_deref().unwrap_or("month"); // Overview + reading status in one query let overview_row = sqlx::query( r#" @@ -285,20 +297,74 @@ pub async fn get_stats( }) .collect(); - // Additions over time (last 12 months) - let additions_rows = sqlx::query( - r#" - SELECT - TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month, - COUNT(*) AS books_added - FROM books - WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months' - GROUP BY DATE_TRUNC('month', created_at) - ORDER BY month ASC - "#, - ) - .fetch_all(&state.pool) - .await?; + // Additions over time (with gap filling) + let additions_rows = match period { + "day" => { + sqlx::query( + r#" + SELECT + TO_CHAR(d.dt, 'YYYY-MM-DD') AS month, + 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 + WHERE created_at >= CURRENT_DATE - INTERVAL '6 days' + GROUP BY created_at::date + ) cnt ON cnt.dt = d.dt + ORDER BY month ASC + "#, + ) + .fetch_all(&state.pool) + .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 = additions_rows .iter() @@ -409,21 +475,77 @@ pub async fn get_stats( }) .collect(); - // Reading activity over time (last 12 months) - let reading_time_rows = sqlx::query( - r#" - SELECT - TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month, - 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) - ORDER BY month ASC - "#, - ) - .fetch_all(&state.pool) - .await?; + // Reading activity over time (with gap filling) + let reading_time_rows = match period { + "day" => { + sqlx::query( + r#" + SELECT + TO_CHAR(d.dt, 'YYYY-MM-DD') AS month, + 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 + WHERE brp.status = 'read' + AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days' + GROUP BY brp.last_read_at::date + ) cnt ON cnt.dt = d.dt + ORDER BY month ASC + "#, + ) + .fetch_all(&state.pool) + .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 = reading_time_rows .iter() diff --git a/apps/backoffice/app/components/PeriodToggle.tsx b/apps/backoffice/app/components/PeriodToggle.tsx new file mode 100644 index 0000000..49c4d18 --- /dev/null +++ b/apps/backoffice/app/components/PeriodToggle.tsx @@ -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 ( +
+ {options.map((p) => ( + + ))} +
+ ); +} diff --git a/apps/backoffice/app/page.tsx b/apps/backoffice/app/page.tsx index e128cd6..22f02b7 100644 --- a/apps/backoffice/app/page.tsx +++ b/apps/backoffice/app/page.tsx @@ -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 && ( - - - {t("dashboard.readingActivity")} - - - ({ label: m.month.slice(5), value: m.books_read }))} - color="hsl(142 60% 45%)" - /> - - - )} + + + {t("dashboard.readingActivity")} + + + + ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))} + color="hsl(142 60% 45%)" + /> + + {/* Charts row */}
@@ -351,15 +376,16 @@ export default async function DashboardPage() {
- {/* Monthly additions line chart – full width */} + {/* Additions line chart – full width */} - + {t("dashboard.booksAdded")} + ({ 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%)" /> diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index bffb448..f7ab444 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -584,8 +584,9 @@ export type StatsResponse = { metadata: MetadataStats; }; -export async function fetchStats() { - return apiFetch("/stats", { next: { revalidate: 30 } }); +export async function fetchStats(period?: "day" | "week" | "month") { + const params = period && period !== "month" ? `?period=${period}` : ""; + return apiFetch(`/stats${params}`, { next: { revalidate: 30 } }); } // --------------------------------------------------------------------------- diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 3880106..88d2919 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -70,7 +70,10 @@ const en: Record = { "dashboard.readingStatus": "Reading status", "dashboard.byFormat": "By format", "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.noSeries": "No series yet", "dashboard.unknown": "Unknown", @@ -84,7 +87,7 @@ const en: Record = { "dashboard.withIsbn": "With ISBN", "dashboard.currentlyReading": "Currently reading", "dashboard.recentlyRead": "Recently read", - "dashboard.readingActivity": "Reading activity (last 12 months)", + "dashboard.readingActivity": "Reading activity", "dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.noCurrentlyReading": "No books in progress", "dashboard.noRecentlyRead": "No books read recently", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 342a1dd..c94ff80 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -68,7 +68,10 @@ const fr = { "dashboard.readingStatus": "Statut de lecture", "dashboard.byFormat": "Par format", "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.noSeries": "Aucune série pour le moment", "dashboard.unknown": "Inconnu", @@ -82,7 +85,7 @@ const fr = { "dashboard.withIsbn": "Avec ISBN", "dashboard.currentlyReading": "En cours de lecture", "dashboard.recentlyRead": "Derniers livres lus", - "dashboard.readingActivity": "Activité de lecture (12 derniers mois)", + "dashboard.readingActivity": "Activité de lecture", "dashboard.pageProgress": "p. {{current}} / {{total}}", "dashboard.noCurrentlyReading": "Aucun livre en cours", "dashboard.noRecentlyRead": "Aucun livre lu récemment",