Carte tendance : deuxieme courbe du nombre de magasins vendeurs

Une quantite qui monte parce que le produit est diffuse dans plus de
magasins ne raconte pas la meme histoire qu'une quantite qui monte a
diffusion constante. La courbe des quantites seule ne permettait pas de
distinguer un succes produit d'un simple elargissement.

- computeStoresSeries() : serie « magasins vendeurs » sur la MEME fenetre
  12 mois que la tendance, avec la meme regle du tout ou rien — les 12
  cles doivent etre presentes, sinon null. Un mois non extrait ne doit
  pas se lire comme « zero magasin », ce qui simulerait un arret de
  diffusion.
- nbMagReseauByMonth remonte du cache (metricsByMonth, deja stocke par la
  sync) jusqu'a la ligne de grille.
- NetworkLineChart accepte une serie `stores` optionnelle.

DEUX ECHELLES INDEPENDANTES : les quantites se comptent en milliers, les
magasins plafonnent a ~270. Sur une echelle commune la courbe des
magasins serait ecrasee sur l'axe et ne montrerait rien. Chaque serie est
normalisee sur son propre maximum, rappele dans la legende — c'est la
FORME des deux courbes qu'on compare, pas leurs hauteurs. La courbe des
magasins est pointillee et indigo, pour ne jamais entrer en collision
avec le vert/rouge/gris de la tendance.

Branchee sur la carte de la Grille et sur celle de la fiche produit. La
sparkline de la colonne reste a une seule courbe : 46x18 px, deux traces
y seraient illisibles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-08-01 09:07:39 +00:00
1 parent b6225f1920
commit abc704a363
6 files changed
+160 -8

No files matched your search

+18 -2
View File
@@ -9,7 +9,7 @@ import {
} from "lucide-react";
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
import { NetworkLineChart, DualLineChart, TrendSparkline } from "@/features/grid/components/network-charts";
import { computeNetworkTrend, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { computeNetworkTrend, computeStoresSeries, trendLabel, TREND_COLOR, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { formatMonthLabel, formatDate, SITE_LABELS, ffMonthToQlik } from "@/features/grid/lib/months";
import { useQlikSyncJob } from "@/features/qlik-sync/use-qlik-sync-job";
import { computeComparatif, indiceVerdict, normalizeMargePct } from "@/features/produits/lib/compare-reseau";
@@ -149,6 +149,17 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]);
/** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */
const magasinsParMois = useMemo(() => {
const detailMensuel = reseau?.metricsByMonth;
if (!detailMensuel) return null;
const parMois: Record<string, number> = {};
for (const [mois, mesures] of Object.entries(detailMensuel)) {
const nb = Number(mesures?.nbMag);
if (Number.isFinite(nb)) parMois[mois] = nb;
}
return computeStoresSeries(parMois);
}, [reseau]);
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
const verdict = indiceVerdict(comparatif.indiceQte);
@@ -280,7 +291,12 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantités vendues réseau, mois par mois
</div>
<NetworkLineChart labels={trend.labels} values={trend.values} color={TREND_COLOR[trend.direction]} />
<NetworkLineChart
labels={trend.labels}
values={trend.values}
color={TREND_COLOR[trend.direction]}
stores={magasinsParMois?.values}
/>
</div>
)}
+20
View File
@@ -484,6 +484,25 @@ async function reconcileSelectedStoreFromMensuelApi(
}
}
/**
* Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
*
* `metricsByMonth` porte les cinq mesures Qlik du mois ; seule `nbMag` sert à la
* deuxième courbe de la carte tendance. Renvoie `null` plutôt qu'un objet vide
* pour que l'UI distingue « pas de donnée » de « zéro magasin ».
*/
function nbMagParMois(
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
for (const [mois, mesures] of Object.entries(metricsByMonth)) {
const nb = Number(mesures?.nbMag);
if (Number.isFinite(nb)) parMois[mois] = nb;
}
return Object.keys(parMois).length > 0 ? parMois : null;
}
/**
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
@@ -514,6 +533,7 @@ async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Pr
product.margePctReseau = m.margePctReseau;
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
product.qteReseauByMonth = m.qteByMonth ?? null;
product.nbMagReseauByMonth = nbMagParMois(m.metricsByMonth);
product.networkFetchedAt = m.fetchedAt ?? undefined;
}
}
@@ -35,6 +35,7 @@ import {
} from "@/features/grid/lib/months";
import {
computeNetworkTrend,
computeStoresSeries,
trendLabel,
TREND_COLOR,
} from "@/features/grid/lib/network-trend";
@@ -198,6 +199,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
const trend = computeNetworkTrend(row.qteReseauByMonth);
const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction];
// Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une
// hausse due à l'élargissement de la diffusion d'une vraie accélération.
const magasins = computeStoresSeries(row.nbMagReseauByMonth);
return (
<DialogContent className="max-w-md" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }} onInteractOutside={onClose}>
<DialogHeader>
@@ -219,7 +223,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</div>
) : (
<NetworkLineChart labels={labels} values={values} color={color} />
<NetworkLineChart labels={labels} values={values} color={color} stores={magasins?.values} />
)}
</DialogContent>
);
@@ -43,24 +43,95 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
);
}
/** Courbe unique des ventes réseau mensuelles (SVG). */
export function NetworkLineChart({ labels, values, color }: { labels: string[]; values: number[]; color: string }) {
const W = 460, H = 200;
const padL = 10, padR = 10, padT = 22, padB = 30;
/**
* Couleur de la courbe « magasins vendeurs ».
*
* Indigo choisi pour ne jamais entrer en collision avec les couleurs de
* tendance (vert / rouge / gris) portées par la courbe des quantités, et pour
* rester lisible sur fond clair comme sur fond sombre.
*/
export const STORES_COLOR = "#6366f1";
/**
* Ventes réseau mensuelles, avec en option la courbe du nombre de magasins.
*
* ⚠️ DEUX ÉCHELLES INDÉPENDANTES. Les quantités se comptent en milliers, les
* magasins plafonnent à ~270 : sur une échelle commune la courbe des magasins
* serait écrasée sur l'axe et ne montrerait plus rien. Chaque série est donc
* normalisée sur son propre maximum, rappelé en haut du graphique — c'est la
* FORME des deux courbes qu'on compare, pas leurs hauteurs.
*
* Lecture : quantités qui montent avec les magasins = élargissement de la
* diffusion ; quantités qui montent à magasins constants = vraie accélération
* des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement.
*/
export function NetworkLineChart({
labels,
values,
color,
stores,
}: {
labels: string[];
values: number[];
color: string;
/** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */
stores?: number[] | null;
}) {
const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0;
const W = 460, H = avecMagasins ? 224 : 200;
const padL = 10, padR = 10, padT = avecMagasins ? 40 : 22, padB = 30;
const plotW = W - padL - padR, plotH = H - padT - padB;
const n = values.length;
const maxV = Math.max(...values, 1);
const maxS = avecMagasins ? Math.max(...stores!, 1) : 1;
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => padT + plotH - (v / maxV) * plotH;
const yS = (v: number) => padT + plotH - (v / maxS) * plotH;
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`;
const storePts = avecMagasins
? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ")
: "";
return (
<div className="mt-3 w-full overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ minWidth: 380 }} role="img" aria-label="Ventes réseau mensuelles">
{avecMagasins && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1">
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: color }} />
Quantités vendues <span className="tabular-nums" style={{ color: "var(--text-muted)" }}>(max {maxV.toLocaleString("fr-FR")})</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: STORES_COLOR }} />
Magasins vendeurs <span className="tabular-nums" style={{ color: "var(--text-muted)" }}>(max {maxS.toLocaleString("fr-FR")})</span>
</span>
</div>
)}
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ minWidth: 380 }}
role="img"
aria-label={avecMagasins ? "Ventes réseau mensuelles et nombre de magasins vendeurs" : "Ventes réseau mensuelles"}
>
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */}
{avecMagasins && n > 1 && (
<polyline
points={storePts}
fill="none"
stroke={STORES_COLOR}
strokeWidth={1.75}
strokeDasharray="4 3"
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{avecMagasins && stores!.map((v, i) => (
<circle key={`mag-${labels[i]}`} cx={x(i)} cy={yS(v)} r={2.2} fill={STORES_COLOR} />
))}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
@@ -74,6 +145,12 @@ export function NetworkLineChart({ labels, values, color }: { labels: string[];
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
{/* nb de magasins sous l'axe, juste au-dessus du mois */}
{avecMagasins && (
<text x={x(i)} y={H - 20} textAnchor="middle" fontSize={8} fontWeight={600} fill={STORES_COLOR}>
{Math.round(stores![i]).toLocaleString("fr-FR")}
</text>
)}
</g>
))}
</svg>
+27
View File
@@ -110,6 +110,33 @@ export function computeNetworkTrend(
return { values, labels, direction, pct, hasData: true };
}
/**
* Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
* `computeNetworkTrend`, pour la superposer à la courbe des quantités.
*
* Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit
* est diffusé dans plus de magasins ne raconte pas la même histoire qu'une
* quantité qui monte à diffusion constante. Sans elle, la tendance seule ne
* permet pas de distinguer un succès produit d'un simple élargissement.
*
* Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
* être explicitement présentes, sinon `null` — un mois non extrait ne doit pas
* se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
*/
export function computeStoresSeries(
nbMagByMonth?: Record<string, number> | null,
now: Date = new Date(),
): { values: number[]; labels: string[] } | null {
if (!nbMagByMonth) return null;
const labels = buildRolling12QlikMonths(now);
const complet = labels.every((label) =>
Object.prototype.hasOwnProperty.call(nbMagByMonth, label) &&
Number.isFinite(Number(nbMagByMonth[label])),
);
if (!complet) return null;
return { values: labels.map((l) => Number(nbMagByMonth[l])), labels };
}
/** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */
export function trendLabel(pct: number | null): string {
if (pct == null) return "Stable";
+8
View File
@@ -121,6 +121,14 @@ export interface ProductRow {
tauxPresenceReseau?: number;
/** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */
qteReseauByMonth?: Record<string, number> | null;
/**
* Nombre de magasins vendeurs par mois : { "YYYY-MM": nb }.
*
* Deuxième courbe de la carte tendance : une quantité qui monte parce que le
* produit est diffusé dans plus de magasins ne raconte pas la même histoire
* qu'une quantité qui monte à diffusion constante.
*/
nbMagReseauByMonth?: Record<string, number> | null;
/** Fraîcheur des données réseau (ISO) */
networkFetchedAt?: string;
}