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 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 {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', created_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_added
|
SELECT
|
||||||
FROM books
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE created_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
COALESCE(cnt.books_added, 0) AS books_added
|
||||||
GROUP BY DATE_TRUNC('month', created_at)
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
ORDER BY month ASC
|
LEFT JOIN (
|
||||||
"#,
|
SELECT created_at::date AS dt, COUNT(*) AS books_added
|
||||||
)
|
FROM books
|
||||||
.fetch_all(&state.pool)
|
WHERE created_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
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
|
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 {
|
||||||
r#"
|
"day" => {
|
||||||
SELECT
|
sqlx::query(
|
||||||
TO_CHAR(DATE_TRUNC('month', brp.last_read_at), 'YYYY-MM') AS month,
|
r#"
|
||||||
COUNT(*) AS books_read
|
SELECT
|
||||||
FROM book_reading_progress brp
|
TO_CHAR(d.dt, 'YYYY-MM-DD') AS month,
|
||||||
WHERE brp.status = 'read'
|
COALESCE(cnt.books_read, 0) AS books_read
|
||||||
AND brp.last_read_at >= DATE_TRUNC('month', NOW()) - INTERVAL '11 months'
|
FROM generate_series(CURRENT_DATE - INTERVAL '6 days', CURRENT_DATE, '1 day') AS d(dt)
|
||||||
GROUP BY DATE_TRUNC('month', brp.last_read_at)
|
LEFT JOIN (
|
||||||
ORDER BY month ASC
|
SELECT brp.last_read_at::date AS dt, COUNT(*) AS books_read
|
||||||
"#,
|
FROM book_reading_progress brp
|
||||||
)
|
WHERE brp.status = 'read'
|
||||||
.fetch_all(&state.pool)
|
AND brp.last_read_at >= CURRENT_DATE - INTERVAL '6 days'
|
||||||
.await?;
|
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
|
let reading_over_time: Vec<MonthlyReading> = reading_time_rows
|
||||||
.iter()
|
.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 { 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 className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardHeader>
|
<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>
|
||||||
|
|||||||
@@ -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 } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user