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:
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
) -> Result<Json<StatsResponse>, 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<MonthlyAdditions> = 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<MonthlyReading> = reading_time_rows
|
||||
.iter()
|
||||
|
||||
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal file
47
apps/backoffice/app/components/PeriodToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -584,8 +584,9 @@ export type StatsResponse = {
|
||||
metadata: MetadataStats;
|
||||
};
|
||||
|
||||
export async function fetchStats() {
|
||||
return apiFetch<StatsResponse>("/stats", { next: { revalidate: 30 } });
|
||||
export async function fetchStats(period?: "day" | "week" | "month") {
|
||||
const params = period && period !== "month" ? `?period=${period}` : "";
|
||||
return apiFetch<StatsResponse>(`/stats${params}`, { next: { revalidate: 30 } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -70,7 +70,10 @@ const en: Record<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user