feat: add books/pages metric toggle on reading activity chart
Allow switching between number of books and number of pages on the dashboard reading activity chart. Adds pages_read to the stats API response and a MetricToggle component alongside the existing PeriodToggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,6 +106,7 @@ pub struct RecentlyReadItem {
|
|||||||
pub struct MonthlyReading {
|
pub struct MonthlyReading {
|
||||||
pub month: String,
|
pub month: String,
|
||||||
pub books_read: i64,
|
pub books_read: i64,
|
||||||
|
pub pages_read: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -113,6 +114,7 @@ pub struct UserMonthlyReading {
|
|||||||
pub month: String,
|
pub month: String,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub books_read: i64,
|
pub books_read: i64,
|
||||||
|
pub pages_read: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -516,11 +518,14 @@ pub async fn get_stats(
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
@@ -538,15 +543,18 @@ pub async fn get_stats(
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(
|
FROM generate_series(
|
||||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
DATE_TRUNC('week', NOW()),
|
DATE_TRUNC('week', NOW()),
|
||||||
'1 week'
|
'1 week'
|
||||||
) AS d(dt)
|
) AS d(dt)
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
@@ -564,15 +572,18 @@ pub async fn get_stats(
|
|||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(
|
FROM generate_series(
|
||||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
DATE_TRUNC('month', NOW()),
|
DATE_TRUNC('month', NOW()),
|
||||||
'1 month'
|
'1 month'
|
||||||
) AS d(dt)
|
) AS d(dt)
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read
|
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
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 >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
AND ($1::uuid IS NULL OR brp.user_id = $1)
|
||||||
@@ -592,6 +603,7 @@ pub async fn get_stats(
|
|||||||
.map(|r| MonthlyReading {
|
.map(|r| MonthlyReading {
|
||||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||||
books_read: r.get("books_read"),
|
books_read: r.get("books_read"),
|
||||||
|
pages_read: r.get("pages_read"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -603,12 +615,15 @@ pub async fn get_stats(
|
|||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
u.username,
|
u.username,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
CROSS JOIN users u
|
CROSS JOIN users u
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read
|
SELECT brp.last_read_at::date AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
GROUP BY brp.last_read_at::date, brp.user_id
|
GROUP BY brp.last_read_at::date, brp.user_id
|
||||||
@@ -625,7 +640,8 @@ pub async fn get_stats(
|
|||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
u.username,
|
u.username,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(
|
FROM generate_series(
|
||||||
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
DATE_TRUNC('week', NOW() - INTERVAL '2 months'),
|
||||||
DATE_TRUNC('week', NOW()),
|
DATE_TRUNC('week', NOW()),
|
||||||
@@ -633,8 +649,10 @@ pub async fn get_stats(
|
|||||||
) AS d(dt)
|
) AS d(dt)
|
||||||
CROSS JOIN users u
|
CROSS JOIN users u
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
|
SELECT DATE_TRUNC('week', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
WHERE brp.status = 'read'
|
WHERE brp.status = 'read'
|
||||||
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
AND brp.last_read_at >= DATE_TRUNC('week', NOW() - INTERVAL '2 months')
|
||||||
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
|
GROUP BY DATE_TRUNC('week', brp.last_read_at), brp.user_id
|
||||||
@@ -651,7 +669,8 @@ pub async fn get_stats(
|
|||||||
SELECT
|
SELECT
|
||||||
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
TO_CHAR(d.dt, 'YYYY-MM') AS month,
|
||||||
u.username,
|
u.username,
|
||||||
COALESCE(cnt.books_read, 0) AS books_read
|
COALESCE(cnt.books_read, 0) AS books_read,
|
||||||
|
COALESCE(cnt.pages_read, 0) AS pages_read
|
||||||
FROM generate_series(
|
FROM generate_series(
|
||||||
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
DATE_TRUNC('month', NOW()) - INTERVAL '11 months',
|
||||||
DATE_TRUNC('month', NOW()),
|
DATE_TRUNC('month', NOW()),
|
||||||
@@ -659,8 +678,10 @@ pub async fn get_stats(
|
|||||||
) AS d(dt)
|
) AS d(dt)
|
||||||
CROSS JOIN users u
|
CROSS JOIN users u
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read
|
SELECT DATE_TRUNC('month', brp.last_read_at) AS dt, brp.user_id, COUNT(*) AS books_read,
|
||||||
|
COALESCE(SUM(b.page_count), 0)::BIGINT AS pages_read
|
||||||
FROM book_reading_progress brp
|
FROM book_reading_progress brp
|
||||||
|
JOIN books b ON b.id = brp.book_id
|
||||||
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 >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
||||||
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
|
GROUP BY DATE_TRUNC('month', brp.last_read_at), brp.user_id
|
||||||
@@ -679,6 +700,7 @@ pub async fn get_stats(
|
|||||||
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
month: r.get::<Option<String>, _>("month").unwrap_or_default(),
|
||||||
username: r.get("username"),
|
username: r.get("username"),
|
||||||
books_read: r.get("books_read"),
|
books_read: r.get("books_read"),
|
||||||
|
pages_read: r.get("pages_read"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fetchStats, fetchUsers, StatsResponse, UserDto } from "@/lib/api";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/app/components/ui";
|
||||||
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
import { RcDonutChart, RcBarChart, RcAreaChart, RcStackedBar, RcHorizontalBar, RcMultiLineChart } from "@/app/components/DashboardCharts";
|
||||||
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
import { PeriodToggle } from "@/app/components/PeriodToggle";
|
||||||
|
import { MetricToggle } from "@/app/components/MetricToggle";
|
||||||
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
import { CurrentlyReadingList, RecentlyReadList } from "@/app/components/ReadingUserFilter";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getServerTranslations } from "@/lib/i18n/server";
|
import { getServerTranslations } from "@/lib/i18n/server";
|
||||||
@@ -67,6 +68,7 @@ export default async function DashboardPage({
|
|||||||
const searchParamsAwaited = await searchParams;
|
const searchParamsAwaited = await searchParams;
|
||||||
const rawPeriod = searchParamsAwaited.period;
|
const rawPeriod = searchParamsAwaited.period;
|
||||||
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
const period = rawPeriod === "day" ? "day" as const : rawPeriod === "week" ? "week" as const : "month" as const;
|
||||||
|
const metric = searchParamsAwaited.metric === "pages" ? "pages" as const : "books" as const;
|
||||||
const { t, locale } = await getServerTranslations();
|
const { t, locale } = await getServerTranslations();
|
||||||
|
|
||||||
let stats: StatsResponse | null = null;
|
let stats: StatsResponse | null = null;
|
||||||
@@ -179,7 +181,10 @@ export default async function DashboardPage({
|
|||||||
<Card hover={false}>
|
<Card hover={false}>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<MetricToggle labels={{ books: t("dashboard.metricBooks"), pages: t("dashboard.metricPages") }} />
|
||||||
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
<PeriodToggle labels={{ day: t("dashboard.periodDay"), week: t("dashboard.periodWeek"), month: t("dashboard.periodMonth") }} />
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -187,12 +192,13 @@ export default async function DashboardPage({
|
|||||||
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
|
"hsl(142 60% 45%)", "hsl(198 78% 37%)", "hsl(45 93% 47%)",
|
||||||
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
"hsl(2 72% 48%)", "hsl(280 60% 50%)", "hsl(32 80% 50%)",
|
||||||
];
|
];
|
||||||
|
const dataKey = metric === "pages" ? "pages_read" : "books_read";
|
||||||
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
|
const usernames = [...new Set(users_reading_over_time.map(r => r.username))];
|
||||||
if (usernames.length === 0) {
|
if (usernames.length === 0) {
|
||||||
return (
|
return (
|
||||||
<RcAreaChart
|
<RcAreaChart
|
||||||
noDataLabel={noDataLabel}
|
noDataLabel={noDataLabel}
|
||||||
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m.books_read }))}
|
data={reading_over_time.map((m) => ({ label: formatChartLabel(m.month, period, locale), value: m[dataKey] }))}
|
||||||
color="hsl(142 60% 45%)"
|
color="hsl(142 60% 45%)"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -202,7 +208,7 @@ export default async function DashboardPage({
|
|||||||
for (const row of users_reading_over_time) {
|
for (const row of users_reading_over_time) {
|
||||||
const label = formatChartLabel(row.month, period, locale);
|
const label = formatChartLabel(row.month, period, locale);
|
||||||
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
|
if (!byMonth.has(row.month)) byMonth.set(row.month, { label });
|
||||||
byMonth.get(row.month)![row.username] = row.books_read;
|
byMonth.get(row.month)![row.username] = row[dataKey];
|
||||||
}
|
}
|
||||||
const chartData = [...byMonth.values()];
|
const chartData = [...byMonth.values()];
|
||||||
const lines = usernames.map((u, i) => ({
|
const lines = usernames.map((u, i) => ({
|
||||||
|
|||||||
47
apps/backoffice/app/components/MetricToggle.tsx
Normal file
47
apps/backoffice/app/components/MetricToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type Metric = "books" | "pages";
|
||||||
|
|
||||||
|
export function MetricToggle({
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
labels: { books: string; pages: string };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const raw = searchParams.get("metric");
|
||||||
|
const current: Metric = raw === "pages" ? "pages" : "books";
|
||||||
|
|
||||||
|
function setMetric(metric: Metric) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (metric === "books") {
|
||||||
|
params.delete("metric");
|
||||||
|
} else {
|
||||||
|
params.set("metric", metric);
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `?${qs}` : "/", { scroll: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: Metric[] = ["books", "pages"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 bg-muted rounded-lg p-0.5">
|
||||||
|
{options.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => setMetric(m)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
current === m
|
||||||
|
? "bg-card text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{labels[m]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -699,12 +699,14 @@ export type RecentlyReadItem = {
|
|||||||
export type MonthlyReading = {
|
export type MonthlyReading = {
|
||||||
month: string;
|
month: string;
|
||||||
books_read: number;
|
books_read: number;
|
||||||
|
pages_read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserMonthlyReading = {
|
export type UserMonthlyReading = {
|
||||||
month: string;
|
month: string;
|
||||||
username: string;
|
username: string;
|
||||||
books_read: number;
|
books_read: number;
|
||||||
|
pages_read: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JobTimePoint = {
|
export type JobTimePoint = {
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ const en: Record<TranslationKey, string> = {
|
|||||||
"dashboard.periodDay": "Day",
|
"dashboard.periodDay": "Day",
|
||||||
"dashboard.periodWeek": "Week",
|
"dashboard.periodWeek": "Week",
|
||||||
"dashboard.periodMonth": "Month",
|
"dashboard.periodMonth": "Month",
|
||||||
|
"dashboard.metricBooks": "Books",
|
||||||
|
"dashboard.metricPages": "Pages",
|
||||||
"dashboard.popularSeries": "Popular series",
|
"dashboard.popularSeries": "Popular series",
|
||||||
"dashboard.noSeries": "No series yet",
|
"dashboard.noSeries": "No series yet",
|
||||||
"dashboard.unknown": "Unknown",
|
"dashboard.unknown": "Unknown",
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ const fr = {
|
|||||||
"dashboard.periodDay": "Jour",
|
"dashboard.periodDay": "Jour",
|
||||||
"dashboard.periodWeek": "Semaine",
|
"dashboard.periodWeek": "Semaine",
|
||||||
"dashboard.periodMonth": "Mois",
|
"dashboard.periodMonth": "Mois",
|
||||||
|
"dashboard.metricBooks": "Livres",
|
||||||
|
"dashboard.metricPages": "Pages",
|
||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user