// ----------------------------------------------------------------------------- // Food-Ka — Agrégateur de produits d'épicerie (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // pages/Stats.tsx : observatoire du marché — consomme /api/stats/detailed // · Tuiles héro (produits, soldes, bannières, marques, prix médian, activité 7 j) // · 🧺 Panier comparatif : articles courants × bannières (prix médians) // · Bannières en chiffres (tableau triable, mini-barres de prix médian) // · Matrice catégorie × bannière (teinte chaleur, moins cher en vert) // · Distribution des prix, baisses de prix (7 j) // · Meilleures aubaines + journal des synchronisations (/api/stats) // ----------------------------------------------------------------------------- import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { API_BASE, BasketTotal, DetailedStats, SourceMarketStats, Stats, fetchSources, fetchStats, fmtTs, registerSourceNames, sourceName, sourceShort, statsDetailed, } from "../api"; import ProductCard from "../components/ProductCard"; import SourceLogo from "../components/SourceLogo"; const fmt = (n: number | null | undefined) => n == null ? "—" : n.toLocaleString("fr-CA"); const fmt$ = (n: number | null | undefined) => n == null ? "—" : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`; const fmtPct = (n: number | null | undefined, digits = 0) => n == null ? "—" : `${n.toLocaleString("fr-CA", { maximumFractionDigits: digits })} %`; function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) { return (
{v}
{k}
); } // clés numériques triables du tableau « bannières en chiffres » type BannerSortKey = "n" | "median_price" | "sale_share" | "avg_discount_pct" | "max_discount_pct"; export default function StatsPage() { const [d, setD] = useState(null); // /api/stats (aubaines + journal) const [m, setM] = useState(null); // /api/stats/detailed const [error, setError] = useState(null); const [sortKey, setSortKey] = useState("n"); const [sortDir, setSortDir] = useState<-1 | 1>(-1); useEffect(() => { fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {}); fetchStats().then(setD).catch((e) => setError(String(e))); statsDetailed().then(setM).catch((e) => setError(String(e))); }, []); if (error) return (
⚠️

Statistiques indisponibles

{error}

); if (!m || !d) return (
{Array.from({ length: 6 }).map((_, i) => (
))}
); const g = m.global; // ---- panier comparatif ----------------------------------------------------- // colonnes = bannières couvrant assez d'articles, déjà triées du moins cher const basketCols = m.basket_totals.map((t) => t.source); const bestBasket: BasketTotal | null = m.basket_totals[0] ?? null; const totalBySrc: Record = {}; for (const t of m.basket_totals) totalBySrc[t.source] = t; // ---- tableau bannières (triable) -------------------------------------------- const numOf = (r: SourceMarketStats, k: BannerSortKey): number => (r[k] ?? -Infinity) as number; const bannerRows = [...m.by_source].sort( (a, b) => (numOf(a, sortKey) - numOf(b, sortKey)) * sortDir || b.n - a.n ); const maxMedian = Math.max(...m.by_source.map((r) => r.median_price ?? 0), 0.01); const sortBy = (k: BannerSortKey) => { if (sortKey === k) setSortDir((dir) => (dir === -1 ? 1 : -1)); else { setSortKey(k); setSortDir(-1); } }; const Th = ({ k, label }: { k: BannerSortKey; label: string }) => ( sortBy(k)} title="Trier" > {label}{sortKey === k ? (sortDir === -1 ? " ▾" : " ▴") : ""} ); // ---- matrice catégorie × bannière -------------------------------------------- const matrixSources = m.by_source.slice(0, 9).map((s) => s.source); const matrixRows = Object.entries(m.category_matrix) .map(([cat, per]) => ({ cat, per, total: Object.values(per).reduce((s, c) => s + c.n, 0), })) .sort((a, b) => b.total - a.total); // ---- distribution des prix --------------------------------------------------- const distMax = Math.max(...m.price_distribution.map((b) => b.n), 1); return (
Observatoire — prix d'épicerie au Québec

L'épicerie, en chiffres

↓ Télécharger le rapport PDF

Calculé en direct sur les {fmt(g.total)} produits actifs de {fmt(g.sources)} bannières, répartis dans {fmt(g.categories)} catégories et {fmt(g.brands)} marques.

{/* ---- 1 · tuiles héro ---- */}
{/* ---- 2 · panier comparatif ---- */}

🧺 Panier comparatif

articles courants × bannières — prix médian des produits correspondants

{basketCols.length > 0 ? ( <>
{basketCols.map((src) => ( ))} {m.basket.map((row) => { const prices = basketCols .map((src) => row.by_source[src]?.median_price) .filter((v): v is number => v != null); const min = prices.length ? Math.min(...prices) : null; return ( {basketCols.map((src) => { const cell = row.by_source[src]; const v = cell?.median_price ?? null; const cls = [ v != null && v === min ? "cell-best" : "", src === bestBasket?.source ? "best-col" : "", ].join(" ").trim(); return ( ); })} ); })} {basketCols.map((src) => { const t = totalBySrc[src]; return ( ); })}
Article {sourceShort(src)}
{row.item} {fmt$(v)}
Total du panier {fmt$(t?.total)} {t ? `${t.items}/${m.basket.length} articles` : ""}
{bestBasket && (
🏆 Panier le moins cher : {sourceName(bestBasket.source)} —{" "} {fmt$(bestBasket.total)} pour {bestBasket.items} articles
)}

Prix médian des produits correspondant à chaque article chez la bannière ; seules les bannières couvrant la majorité du panier sont comparées.

) : (

Pas encore assez de données pour composer le panier — il se remplit à mesure que les bannières sont synchronisées.

)}
{/* ---- 3 · bannières en chiffres ---- */}

Bannières en chiffres

cliquez un en-tête pour trier · cliquez une bannière pour filtrer

{bannerRows.map((r) => ( ))}
Bannière
{fmt(r.n)} {fmt$(r.median_price)} 0 ? "pct-sale" : ""}> {fmtPct(r.sale_share * 100)} {fmtPct(r.avg_discount_pct, 1)} {fmtPct(r.max_discount_pct, 1)}
{/* ---- 4 · matrice catégorie × bannière ---- */}

Prix médian par catégorie et bannière

{matrixSources.length} plus grandes bannières — le moins cher de chaque rangée en vert

{matrixSources.map((src) => ( ))} {matrixRows.map(({ cat, per }) => { const vals = matrixSources .map((src) => per[src]?.median_price) .filter((v): v is number => v != null); const min = vals.length ? Math.min(...vals) : null; const max = vals.length ? Math.max(...vals) : null; return ( {matrixSources.map((src) => { const cell = per[src]; const v = cell?.median_price ?? null; let style: React.CSSProperties | undefined; if (v != null && min != null && max != null) { if (v === min) style = { background: "rgba(46, 158, 99, 0.16)" }; else if (max > min) { const t = (v - min) / (max - min); style = { background: `rgba(232, 84, 47, ${(0.05 + 0.2 * t).toFixed(3)})` }; } } return ( ); })} ); })}
Catégorie {sourceShort(src)}
{cat} {fmt$(v)}
moins cher plus cher
{/* ---- 5 · distribution des prix ---- */}

Distribution des prix

produits actifs par palier de prix

{m.price_distribution.map((b) => (
{b.range} {fmt(b.n)}
))}
{/* ---- 6 · baisses de prix (7 j) ---- */}

📉 Baisses de prix (7 jours)

produits dont le prix relevé a diminué depuis la dernière synchronisation

{m.price_drops.length > 0 ? (
    {m.price_drops.slice(0, 20).map((dr, i) => (
  • {dr.name} {fmt$(dr.old_price)}{fmt$(dr.new_price)} −{dr.drop_pct.toLocaleString("fr-CA")} %
  • ))}
) : (

Aucune baisse détectée encore — l'historique se construit à chaque synchronisation.

)}
{/* ---- aubaines & journal (depuis /api/stats) ---- */} {d.deals.length > 0 && (

Meilleures aubaines du moment 🔥

rabais relatif le plus fort, toutes bannières confondues

{d.deals.slice(0, 12).map((p) => ( ))}

Voir toutes les aubaines →

)} {d.recent_syncs.length > 0 && (

Dernières synchronisations

    {d.recent_syncs.map((s) => (
  • {s.ok ? "✅" : "⚠️"} {sourceName(s.source)} — {fmtTs(s.ts)} ·{" "} {fmt(s.found)} produits trouvés, {fmt(s.added)} ajoutés,{" "} {fmt(s.updated)} mis à jour, {fmt(s.removed)} retirés {s.message ? ` — ${s.message}` : ""}
  • ))}
)}

Données recalculées à chaque synchronisation. Les catégories et bannières renvoient vers les produits filtrés correspondants.{" "} Voir le registre complet des sources →

); }