// ----------------------------------------------------------------------------- // 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 (
{error}
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 ---- */}articles courants × bannières — prix médian des produits correspondants
{basketCols.length > 0 ? ( <>| Article | {basketCols.map((src) => (
|
))}
|---|---|
| {row.item} | {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 ({fmt$(v)} | ); })}
| Total du panier | {basketCols.map((src) => { const t = totalBySrc[src]; return ({fmt$(t?.total)} {t ? `${t.items}/${m.basket.length} 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.
)}cliquez un en-tête pour trier · cliquez une bannière pour filtrer
| 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)} |
{matrixSources.length} plus grandes bannières — le moins cher de chaque rangée en vert
| Catégorie | {matrixSources.map((src) => (
|
))}
|---|---|
| {cat} | {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 ({fmt$(v)} | ); })}
produits actifs par palier de prix
produits dont le prix relevé a diminué depuis la dernière synchronisation
{m.price_drops.length > 0 ? (Aucune baisse détectée encore — l'historique se construit à chaque synchronisation.
)}rabais relatif le plus fort, toutes bannières confondues
Voir toutes les aubaines →
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 →