From 89c9a732d6f201f9708c282084f3f56c0262b47f Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Thu, 7 May 2026 11:42:36 +0200
Subject: [PATCH] add chart details
---
apps/api/app/services/dashboard.ts | 110 +++++++++++++-----
.../src/components/charts/ClientPaidChart.tsx | 30 +++--
.../src/components/charts/DsoTrendChart.tsx | 31 ++---
.../src/components/charts/EncaisseChart.tsx | 34 ++++--
apps/web/src/components/charts/theme.ts | 41 +++++++
apps/web/src/routes/_app/clients_.$id.tsx | 8 +-
apps/web/src/routes/_app/index.tsx | 13 ++-
apps/web/src/routes/_app/insights.tsx | 26 ++++-
8 files changed, 221 insertions(+), 72 deletions(-)
diff --git a/apps/api/app/services/dashboard.ts b/apps/api/app/services/dashboard.ts
index 9a9fa4b..5582acd 100644
--- a/apps/api/app/services/dashboard.ts
+++ b/apps/api/app/services/dashboard.ts
@@ -156,14 +156,27 @@ export async function topLatePayers(
export type RangeMonths = 3 | 6 | 12
-export type PaidByMonthPoint = {
- /** Premier jour du mois en ISO date "YYYY-MM-01". */
- month: string
- /** Total encaissé sur le mois (centimes). */
+/**
+ * Granularité d'agrégation des séries temporelles.
+ * - `month` : 1 bucket = 1 mois (vue 6m / 12m, lecture macro)
+ * - `week` : 1 bucket = 1 semaine ISO (lundi → dimanche), pour la vue
+ * 3 mois où l'agrégation mensuelle masque trop la dynamique
+ * (3 points seulement, on perd la lecture).
+ */
+export type Granularity = 'month' | 'week'
+
+export type PaidSeriesPoint = {
+ /**
+ * Premier jour du bucket en ISO date "YYYY-MM-DD".
+ * - `month`: premier du mois (toujours -01)
+ * - `week` : lundi de la semaine
+ */
+ bucket: string
+ /** Total encaissé sur le bucket (centimes). */
encaisseCents: number
- /** Nombre de factures payées sur le mois. */
+ /** Nombre de factures payées sur le bucket. */
paidCount: number
- /** DSO moyen sur le mois (jours, 0 si aucun paiement). */
+ /** DSO moyen sur le bucket (jours, 0 si aucun paiement). */
dsoDays: number
}
@@ -175,10 +188,18 @@ export type PipelineSlice = {
export type DashboardTimeseries = {
range: RangeMonths
- paidByMonth: PaidByMonthPoint[]
+ granularity: Granularity
+ /** Garde le nom pour ne pas casser le contrat — mais les buckets peuvent
+ * être hebdo (cf. `granularity`). */
+ paidByMonth: PaidSeriesPoint[]
pipelineByStatus: PipelineSlice[]
}
+/** Choix de granularité par défaut pour un range donné. */
+function pickGranularity(range: RangeMonths): Granularity {
+ return range === 3 ? 'week' : 'month'
+}
+
/**
* Calcule les séries temporelles pour le dashboard / insights.
*
@@ -192,7 +213,8 @@ export async function computeTimeseries(
organizationId: string,
range: RangeMonths = 6
): Promise {
- const paidByMonth = await fetchPaidByMonth({ organizationId, range })
+ const granularity = pickGranularity(range)
+ const paidByMonth = await fetchPaidSeries({ organizationId, range, granularity })
const pipelineRows = (await db
.from('invoices')
@@ -219,7 +241,7 @@ export async function computeTimeseries(
return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 }
})
- return { range, paidByMonth, pipelineByStatus }
+ return { range, granularity, paidByMonth, pipelineByStatus }
}
/** Variante par client — on filtre paidByMonth sur un client_id. */
@@ -227,27 +249,61 @@ export async function computeClientTimeseries(
organizationId: string,
clientId: string,
range: RangeMonths = 6
-): Promise<{ range: RangeMonths; paidByMonth: PaidByMonthPoint[] }> {
- const paidByMonth = await fetchPaidByMonth({ organizationId, clientId, range })
- return { range, paidByMonth }
+): Promise<{ range: RangeMonths; granularity: Granularity; paidByMonth: PaidSeriesPoint[] }> {
+ const granularity = pickGranularity(range)
+ const paidByMonth = await fetchPaidSeries({
+ organizationId,
+ clientId,
+ range,
+ granularity,
+ })
+ return { range, granularity, paidByMonth }
}
/**
* Helper interne — DRY entre computeTimeseries et computeClientTimeseries.
- * Renvoie N buckets mensuels (range derniers mois inclus) avec encaisse/count/dso.
+ *
+ * Renvoie N buckets ordonnés (mensuels ou hebdomadaires selon `granularity`)
+ * couvrant les derniers `range` mois. Chaque bucket porte encaisse/count/dso.
+ *
+ * - `month` : `range` buckets mensuels (1/mois), label = 1er du mois
+ * - `week` : ~`range * 4.5` buckets hebdo (1/semaine), label = lundi de la
+ * semaine. Postgres `date_trunc('week')` aligne sur lundi (ISO),
+ * Luxon `startOf('week')` aussi → cohérence garantie.
*/
-async function fetchPaidByMonth(params: {
+async function fetchPaidSeries(params: {
organizationId: string
clientId?: string
range: RangeMonths
-}): Promise {
+ granularity: Granularity
+}): Promise {
const now = await clock.now(params.organizationId)
- const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
- const buckets = new Map()
- for (let i = 0; i < params.range; i++) {
- const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01')
- buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
+ // Garde-fou : `truncUnit` est interpolé dans du SQL brut, on whitelist.
+ const truncUnit: 'month' | 'week' =
+ params.granularity === 'week' ? 'week' : 'month'
+
+ let firstBucket: DateTime
+ let bucketCount: number
+ let stepUnit: 'months' | 'weeks'
+
+ if (truncUnit === 'week') {
+ // ~range * 4.33 semaines couvrent la fenêtre, on prend ceil pour ne pas
+ // tronquer le premier mois.
+ bucketCount = Math.ceil(params.range * 4.34)
+ firstBucket = now.startOf('week').minus({ weeks: bucketCount - 1 })
+ stepUnit = 'weeks'
+ } else {
+ bucketCount = params.range
+ firstBucket = now.minus({ months: bucketCount - 1 }).startOf('month')
+ stepUnit = 'months'
+ }
+
+ const buckets = new Map()
+ for (let i = 0; i < bucketCount; i++) {
+ const inc = stepUnit === 'weeks' ? { weeks: i } : { months: i }
+ const b = firstBucket.plus(inc).toFormat('yyyy-LL-dd')
+ buckets.set(b, { bucket: b, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
}
const query = db
@@ -260,25 +316,25 @@ async function fetchPaidByMonth(params: {
const rows = (await query
.select(
- db.raw(`to_char(date_trunc('month', paid_at), 'YYYY-MM-01') as month`),
+ db.raw(`to_char(date_trunc('${truncUnit}', paid_at), 'YYYY-MM-DD') as bucket`),
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as encaisse_cents`),
db.raw(`count(*)::int as paid_count`),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days`
)
)
- .groupByRaw(`date_trunc('month', paid_at)`)
- .orderByRaw(`date_trunc('month', paid_at)`)) as Array<{
- month: string
+ .groupByRaw(`date_trunc('${truncUnit}', paid_at)`)
+ .orderByRaw(`date_trunc('${truncUnit}', paid_at)`)) as Array<{
+ bucket: string
encaisse_cents: number
paid_count: number
dso_days: number
}>
for (const r of rows) {
- if (!buckets.has(r.month)) continue
- buckets.set(r.month, {
- month: r.month,
+ if (!buckets.has(r.bucket)) continue
+ buckets.set(r.bucket, {
+ bucket: r.bucket,
encaisseCents: r.encaisse_cents,
paidCount: r.paid_count,
dsoDays: r.dso_days,
diff --git a/apps/web/src/components/charts/ClientPaidChart.tsx b/apps/web/src/components/charts/ClientPaidChart.tsx
index 83284bf..6bb6b87 100644
--- a/apps/web/src/components/charts/ClientPaidChart.tsx
+++ b/apps/web/src/components/charts/ClientPaidChart.tsx
@@ -13,22 +13,28 @@ import { formatEuros } from "@/lib/format";
import {
AXIS_TICK_STYLE,
chartColors,
- formatMonthLong,
- formatMonthShort,
+ formatBucketLong,
+ formatBucketShort,
+ type SeriesGranularity,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
- * Mini bar chart "encaissé par mois" pour la fiche client.
- * Format compact (height 140) + barres rubis-glow / rubis selon que
- * le mois a eu un paiement ou non. Pas d'axe Y, montants dans le tooltip.
+ * Mini bar chart "encaissé par bucket" pour la fiche client.
+ * Format compact (height 140) + barres rubis. Pas d'axe Y, montants dans
+ * le tooltip. Granularité (mensuelle / hebdo) pilotée par prop.
*/
type ClientPaidChartProps = {
- data: Array<{ month: string; encaisseCents: number; paidCount: number }>;
+ data: Array<{ bucket: string; encaisseCents: number; paidCount: number }>;
+ granularity?: SeriesGranularity;
height?: number;
};
-export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) {
+export function ClientPaidChart({
+ data,
+ granularity = "month",
+ height = 140,
+}: ClientPaidChartProps) {
const c = chartColors();
// Si aucun paiement sur la période, on affiche un message — un chart vide
@@ -44,28 +50,30 @@ export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) {
className="flex items-center justify-center text-[12px] italic text-ink-3"
style={{ height }}
>
- Aucun paiement reçu de ce client sur les 6 derniers mois.
+ Aucun paiement reçu de ce client sur la période.
);
}
+ const xInterval = granularity === "week" ? 1 : 0;
return (
formatBucketShort(v, granularity)}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
+ interval={xInterval}
dy={4}
/>
formatBucketLong(v, granularity)}
formatValue={(v, key) =>
key === "encaisseCents"
? formatEuros(v)
diff --git a/apps/web/src/components/charts/DsoTrendChart.tsx b/apps/web/src/components/charts/DsoTrendChart.tsx
index 895fa86..75691fe 100644
--- a/apps/web/src/components/charts/DsoTrendChart.tsx
+++ b/apps/web/src/components/charts/DsoTrendChart.tsx
@@ -12,18 +12,21 @@ import {
import {
AXIS_TICK_STYLE,
chartColors,
- formatMonthLong,
- formatMonthShort,
+ formatBucketLong,
+ formatBucketShort,
+ type SeriesGranularity,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
- * DSO mensuel — line chart en ink (pas rubis pour distinguer du chart
- * encaissé). Une ReferenceLine pointillée à 30j sert de repère visuel
- * (norme LME pour le délai de paiement standard B2B).
+ * DSO — line chart en ink (pas rubis pour distinguer du chart encaissé).
+ * Granularité (mensuelle / hebdo) pilotée par la prop `granularity`.
+ * Une ReferenceLine pointillée à 30j sert de repère visuel (norme LME pour
+ * le délai de paiement standard B2B).
*/
type DsoTrendChartProps = {
- data: Array<{ month: string; dsoDays: number; paidCount: number }>;
+ data: Array<{ bucket: string; dsoDays: number; paidCount: number }>;
+ granularity?: SeriesGranularity;
height?: number;
/** Référence à afficher en pointillé (30j = LME standard). */
reference?: number;
@@ -31,30 +34,32 @@ type DsoTrendChartProps = {
export function DsoTrendChart({
data,
+ granularity = "month",
height = 200,
reference = 30,
}: DsoTrendChartProps) {
const c = chartColors();
- // On ne plotte pas les mois sans paiement — sinon la ligne tombe à 0
- // et raconte une histoire fausse. On filtre ces points et on les
- // gardera comme "trous" visuels (Recharts gère via connectNulls=false
- // si on met null sur la valeur).
+ // On ne plotte pas les buckets sans paiement — sinon la ligne tombe à 0
+ // et raconte une histoire fausse. On met null pour avoir un trou visuel
+ // (Recharts gère via connectNulls=false).
const enriched = data.map((d) => ({
...d,
dsoDays: d.paidCount > 0 ? d.dsoDays : null,
}));
+ const xInterval = granularity === "week" ? 1 : 0;
return (
formatBucketShort(v, granularity)}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
+ interval={xInterval}
dy={6}
/>
formatBucketLong(v, granularity)}
formatValue={(v, key) =>
key === "dsoDays" ? `${Math.round(v)} jours` : String(v)
}
diff --git a/apps/web/src/components/charts/EncaisseChart.tsx b/apps/web/src/components/charts/EncaisseChart.tsx
index cd8ee43..e109c2d 100644
--- a/apps/web/src/components/charts/EncaisseChart.tsx
+++ b/apps/web/src/components/charts/EncaisseChart.tsx
@@ -12,26 +12,37 @@ import { formatEuros } from "@/lib/format";
import {
AXIS_TICK_STYLE,
chartColors,
- formatMonthLong,
- formatMonthShort,
+ formatBucketLong,
+ formatBucketShort,
+ type SeriesGranularity,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
- * Encaissé mensuel — area chart avec dégradé rubis.
- * X = mois (avr, mai, juin…), Y = euros encaissés.
+ * Encaissé — area chart avec dégradé rubis. La granularité de l'axe X dépend
+ * de la prop `granularity` :
+ * - `month` : ticks "mai", tooltip "Mai 2026"
+ * - `week` : ticks "12 mai", tooltip "Semaine du 12 mai 2026"
+ *
* Pas d'axe Y visible : les valeurs sont dans le tooltip pour rester
- * visuellement épuré (le client veut "j'ai gagné combien ce mois", pas
- * une grille graduée).
+ * visuellement épuré.
*/
type EncaisseChartProps = {
- data: Array<{ month: string; encaisseCents: number }>;
+ data: Array<{ bucket: string; encaisseCents: number }>;
+ granularity?: SeriesGranularity;
/** Hauteur fixe en px (défaut 220). */
height?: number;
};
-export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
+export function EncaisseChart({
+ data,
+ granularity = "month",
+ height = 220,
+}: EncaisseChartProps) {
const c = chartColors();
+ // En vue hebdo on a ~13 ticks, le label "12 mai" prend de la place — on en
+ // saute pour aérer (1 sur 2 → 7 visibles).
+ const xInterval = granularity === "week" ? 1 : 0;
return (
@@ -43,18 +54,19 @@ export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
formatBucketShort(v, granularity)}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
+ interval={xInterval}
dy={6}
/>
formatBucketLong(v, granularity)}
formatValue={(v) => formatEuros(v)}
seriesLabel={{ encaisseCents: "Encaissé" }}
/>
diff --git a/apps/web/src/components/charts/theme.ts b/apps/web/src/components/charts/theme.ts
index 407307d..d5e8c67 100644
--- a/apps/web/src/components/charts/theme.ts
+++ b/apps/web/src/components/charts/theme.ts
@@ -60,6 +60,9 @@ export const AXIS_TICK_STYLE: CSSProperties = {
fill: "var(--color-ink-3)",
};
+/** Granularité d'agrégation d'une série temporelle (mensuelle ou hebdo). */
+export type SeriesGranularity = "month" | "week";
+
/** Format mois "2026-04-01" → "avr" pour les ticks X compacts. */
const MONTH_FORMAT = new Intl.DateTimeFormat("fr-FR", { month: "short" });
export function formatMonthShort(monthIso: string): string {
@@ -79,3 +82,41 @@ export function formatMonthLong(monthIso: string): string {
if (Number.isNaN(d.getTime())) return monthIso;
return MONTH_LONG.format(d);
}
+
+/** Format jour court "12 mai" pour les ticks X de la vue hebdo. */
+const DAY_SHORT = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "short",
+});
+export function formatDayShort(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return DAY_SHORT.format(d).replace(".", "");
+}
+
+/** Format "Semaine du 12 mai 2026" pour les tooltips hebdo. */
+const DAY_LONG = new Intl.DateTimeFormat("fr-FR", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+});
+export function formatWeekLong(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return `Semaine du ${DAY_LONG.format(d)}`;
+}
+
+/** Sélecteur unifié — formate un bucket selon la granularité. */
+export function formatBucketShort(
+ iso: string,
+ granularity: SeriesGranularity,
+): string {
+ return granularity === "week" ? formatDayShort(iso) : formatMonthShort(iso);
+}
+
+export function formatBucketLong(
+ iso: string,
+ granularity: SeriesGranularity,
+): string {
+ return granularity === "week" ? formatWeekLong(iso) : formatMonthLong(iso);
+}
diff --git a/apps/web/src/routes/_app/clients_.$id.tsx b/apps/web/src/routes/_app/clients_.$id.tsx
index c1328e7..e12ed48 100644
--- a/apps/web/src/routes/_app/clients_.$id.tsx
+++ b/apps/web/src/routes/_app/clients_.$id.tsx
@@ -34,8 +34,9 @@ import type { ClientWithStats } from "@/components/clients/ClientTable";
type ClientTimeseries = {
range: 3 | 6 | 12;
+ granularity: "month" | "week";
paidByMonth: Array<{
- month: string;
+ bucket: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
@@ -217,7 +218,10 @@ function ClientDetailPage() {
-
+
diff --git a/apps/web/src/routes/_app/index.tsx b/apps/web/src/routes/_app/index.tsx
index c25400c..65dc0f4 100644
--- a/apps/web/src/routes/_app/index.tsx
+++ b/apps/web/src/routes/_app/index.tsx
@@ -30,8 +30,9 @@ import {
type Timeseries = {
range: 3 | 6 | 12;
+ granularity: "month" | "week";
paidByMonth: Array<{
- month: string;
+ bucket: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
@@ -177,7 +178,10 @@ function DashboardPage() {
Détails →
-
+
@@ -198,7 +202,10 @@ function DashboardPage() {
Détails →
-
+
diff --git a/apps/web/src/routes/_app/insights.tsx b/apps/web/src/routes/_app/insights.tsx
index be16703..826920c 100644
--- a/apps/web/src/routes/_app/insights.tsx
+++ b/apps/web/src/routes/_app/insights.tsx
@@ -18,11 +18,13 @@ import {
} from "@/components/charts/PipelineChart";
type Range = 3 | 6 | 12;
+type Granularity = "month" | "week";
type Timeseries = {
range: Range;
+ granularity: Granularity;
paidByMonth: Array<{
- month: string;
+ bucket: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
@@ -104,12 +106,22 @@ function InsightsPage() {
{/* Encaissé — pleine largeur, chart le plus important */}
-
Encaissement mensuel
+
+ {ts?.granularity === "week"
+ ? "Encaissement hebdomadaire"
+ : "Encaissement mensuel"}
+
- Total facturé encaissé chaque mois sur la période sélectionnée.
+ {ts?.granularity === "week"
+ ? "Total encaissé par semaine — la vue 3 mois zoome sur la dynamique récente."
+ : "Total facturé encaissé chaque mois sur la période sélectionnée."}
-
+
{/* DSO + Pipeline côte à côte */}
@@ -126,7 +138,11 @@ function InsightsPage() {
LME pour le B2B.
-
+