spb/food-ka Public
Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 57.7%
TypeScript 24.9%
CSS 16.7%
HTML 0.6%
1// -----------------------------------------------------------------------------2// Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : observatoire du marché — consomme /api/stats/detailed5// · Tuiles héro (produits, soldes, bannières, marques, prix médian, activité 7 j)6// · 🧺 Panier comparatif : articles courants × bannières (prix médians)7// · Bannières en chiffres (tableau triable, mini-barres de prix médian)8// · Matrice catégorie × bannière (teinte chaleur, moins cher en vert)9// · Distribution des prix, baisses de prix (7 j)10// · Meilleures aubaines + journal des synchronisations (/api/stats)11// -----------------------------------------------------------------------------12import { useEffect, useState } from "react";13import { Link } from "react-router-dom";14import {15 BasketTotal, DetailedStats, SourceMarketStats, Stats,16 fetchSources, fetchStats, fmtTs, registerSourceNames,17 sourceName, sourceShort, statsDetailed,18} from "../api";19import ProductCard from "../components/ProductCard";20import SourceLogo from "../components/SourceLogo";2122const fmt = (n: number | null | undefined) =>23 n == null ? "—" : n.toLocaleString("fr-CA");24const fmt$ = (n: number | null | undefined) =>25 n == null26 ? "—"27 : `${n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $`;28const fmtPct = (n: number | null | undefined, digits = 0) =>29 n == null30 ? "—"31 : `${n.toLocaleString("fr-CA", { maximumFractionDigits: digits })} %`;3233function Tile({ v, k, hero }: { v: string; k: string; hero?: boolean }) {34 return (35 <div className={`tile ${hero ? "hero-tile" : ""}`}>36 <div className="tile-v">{v}</div>37 <div className="tile-k">{k}</div>38 </div>39 );40}4142// clés numériques triables du tableau « bannières en chiffres »43type BannerSortKey = "n" | "median_price" | "sale_share" | "avg_discount_pct" | "max_discount_pct";4445export default function StatsPage() {46 const [d, setD] = useState<Stats | null>(null); // /api/stats (aubaines + journal)47 const [m, setM] = useState<DetailedStats | null>(null); // /api/stats/detailed48 const [error, setError] = useState<string | null>(null);49 const [sortKey, setSortKey] = useState<BannerSortKey>("n");50 const [sortDir, setSortDir] = useState<-1 | 1>(-1);5152 useEffect(() => {53 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});54 fetchStats().then(setD).catch((e) => setError(String(e)));55 statsDetailed().then(setM).catch((e) => setError(String(e)));56 }, []);5758 if (error)59 return (60 <div className="notice container">61 <div className="big">⚠️</div>62 <h2>Statistiques indisponibles</h2>63 <p>{error}</p>64 </div>65 );6667 if (!m || !d)68 return (69 <div className="container stats-page" aria-busy="true">70 <div className="skel" style={{ height: 110, marginTop: 40 }} />71 <div className="tiles" style={{ marginTop: 20 }}>72 {Array.from({ length: 6 }).map((_, i) => (73 <div className="skel" key={i} style={{ height: 92 }} />74 ))}75 </div>76 <div className="skel" style={{ height: 320, marginTop: 8 }} />77 <div className="skel" style={{ height: 260, marginTop: 22 }} />78 </div>79 );8081 const g = m.global;8283 // ---- panier comparatif -----------------------------------------------------84 // colonnes = bannières couvrant assez d'articles, déjà triées du moins cher85 const basketCols = m.basket_totals.map((t) => t.source);86 const bestBasket: BasketTotal | null = m.basket_totals[0] ?? null;87 const totalBySrc: Record<string, BasketTotal> = {};88 for (const t of m.basket_totals) totalBySrc[t.source] = t;8990 // ---- tableau bannières (triable) --------------------------------------------91 const numOf = (r: SourceMarketStats, k: BannerSortKey): number =>92 (r[k] ?? -Infinity) as number;93 const bannerRows = [...m.by_source].sort(94 (a, b) => (numOf(a, sortKey) - numOf(b, sortKey)) * sortDir || b.n - a.n95 );96 const maxMedian = Math.max(...m.by_source.map((r) => r.median_price ?? 0), 0.01);97 const sortBy = (k: BannerSortKey) => {98 if (sortKey === k) setSortDir((dir) => (dir === -1 ? 1 : -1));99 else { setSortKey(k); setSortDir(-1); }100 };101 const Th = ({ k, label }: { k: BannerSortKey; label: string }) => (102 <th103 className={`sortable ${sortKey === k ? "sorted" : ""}`}104 onClick={() => sortBy(k)}105 title="Trier"106 >107 {label}{sortKey === k ? (sortDir === -1 ? " ▾" : " ▴") : ""}108 </th>109 );110111 // ---- matrice catégorie × bannière --------------------------------------------112 const matrixSources = m.by_source.slice(0, 9).map((s) => s.source);113 const matrixRows = Object.entries(m.category_matrix)114 .map(([cat, per]) => ({115 cat, per,116 total: Object.values(per).reduce((s, c) => s + c.n, 0),117 }))118 .sort((a, b) => b.total - a.total);119120 // ---- distribution des prix ---------------------------------------------------121 const distMax = Math.max(...m.price_distribution.map((b) => b.n), 1);122123 return (124 <div className="container stats-page">125 <span className="kicker">Observatoire — prix d'épicerie au Québec</span>126 <div className="stats-head">127 <h1 className="stats-title">L'épicerie, en chiffres</h1>128 <a className="btn btn-primary btn-pdf" href="/api/stats/rapport.pdf" download>129 ↓ Télécharger le rapport PDF130 </a>131 </div>132 <p className="sub">133 Calculé en direct sur les {fmt(g.total)} produits actifs de {fmt(g.sources)} bannières,134 répartis dans {fmt(g.categories)} catégories et {fmt(g.brands)} marques.135 </p>136137 {/* ---- 1 · tuiles héro ---- */}138 <div className="tiles">139 <Tile hero v={fmt(g.total)} k="produits suivis" />140 <Tile v={fmtPct(g.sale_share * 100)} k={`en solde (${fmt(g.on_sale)} produits)`} />141 <Tile v={fmt(g.sources)} k="bannières connectées" />142 <Tile v={fmt(g.brands)} k="marques" />143 <Tile v={fmt$(g.median_price)} k="prix médian global" />144 <Tile v={fmt(g.price_changes_7d)} k="changements de prix (7 j)" />145 </div>146147 {/* ---- 2 · panier comparatif ---- */}148 <section className="viz-card">149 <h2>🧺 Panier comparatif</h2>150 <p className="viz-sub">151 articles courants × bannières — prix médian des produits correspondants152 </p>153 {basketCols.length > 0 ? (154 <>155 <div className="basket-wrap">156 <table className="basket-table">157 <thead>158 <tr>159 <th>Article</th>160 {basketCols.map((src) => (161 <th key={src}162 className={src === bestBasket?.source ? "best-col" : ""}>163 <span className="basket-th">164 <SourceLogo source={src} size={28} fallback="hide" />165 {sourceShort(src)}166 </span>167 </th>168 ))}169 </tr>170 </thead>171 <tbody>172 {m.basket.map((row) => {173 const prices = basketCols174 .map((src) => row.by_source[src]?.median_price)175 .filter((v): v is number => v != null);176 const min = prices.length ? Math.min(...prices) : null;177 return (178 <tr key={row.item}>179 <th>{row.item}</th>180 {basketCols.map((src) => {181 const cell = row.by_source[src];182 const v = cell?.median_price ?? null;183 const cls = [184 v != null && v === min ? "cell-best" : "",185 src === bestBasket?.source ? "best-col" : "",186 ].join(" ").trim();187 return (188 <td key={src} className={cls}189 title={cell ? `${fmt(cell.n)} produits correspondants` : undefined}>190 {fmt$(v)}191 </td>192 );193 })}194 </tr>195 );196 })}197 </tbody>198 <tfoot>199 <tr>200 <th>Total du panier</th>201 {basketCols.map((src) => {202 const t = totalBySrc[src];203 return (204 <td key={src}205 className={src === bestBasket?.source ? "best-col cell-best" : ""}>206 {fmt$(t?.total)}207 <small>{t ? `${t.items}/${m.basket.length} articles` : ""}</small>208 </td>209 );210 })}211 </tr>212 </tfoot>213 </table>214 </div>215 {bestBasket && (216 <div className="basket-callout">217 🏆 Panier le moins cher : <b>{sourceName(bestBasket.source)}</b> —{" "}218 {fmt$(bestBasket.total)} pour {bestBasket.items} articles219 </div>220 )}221 <p className="stats-foot">222 Prix médian des produits correspondant à chaque article chez la bannière ;223 seules les bannières couvrant la majorité du panier sont comparées.224 </p>225 </>226 ) : (227 <p className="fine">228 Pas encore assez de données pour composer le panier — il se remplit à229 mesure que les bannières sont synchronisées.230 </p>231 )}232 </section>233234 {/* ---- 3 · bannières en chiffres ---- */}235 <section className="viz-card">236 <h2>Bannières en chiffres</h2>237 <p className="viz-sub">cliquez un en-tête pour trier · cliquez une bannière pour filtrer</p>238 <div className="stat-table-wrap">239 <table className="stat-table">240 <thead>241 <tr>242 <th>Bannière</th>243 <Th k="n" label="Produits" />244 <Th k="median_price" label="Prix médian" />245 <Th k="sale_share" label="En solde" />246 <Th k="avg_discount_pct" label="Rabais moyen" />247 <Th k="max_discount_pct" label="Rabais max" />248 </tr>249 </thead>250 <tbody>251 {bannerRows.map((r) => (252 <tr key={r.source}>253 <td>254 <Link to={`/?source=${encodeURIComponent(r.source)}`}>255 <SourceLogo source={r.source} size={22} name="short" />256 </Link>257 </td>258 <td>{fmt(r.n)}</td>259 <td>260 {fmt$(r.median_price)}261 <span className="mini-track" aria-hidden="true">262 <span className="mini-fill"263 style={{ width: `${((r.median_price ?? 0) / maxMedian) * 100}%` }} />264 </span>265 </td>266 <td className={r.sale_share > 0 ? "pct-sale" : ""}>267 {fmtPct(r.sale_share * 100)}268 </td>269 <td>{fmtPct(r.avg_discount_pct, 1)}</td>270 <td>{fmtPct(r.max_discount_pct, 1)}</td>271 </tr>272 ))}273 </tbody>274 </table>275 </div>276 </section>277278 {/* ---- 4 · matrice catégorie × bannière ---- */}279 <section className="viz-card">280 <h2>Prix médian par catégorie et bannière</h2>281 <p className="viz-sub">282 {matrixSources.length} plus grandes bannières — le moins cher de chaque rangée en vert283 </p>284 <div className="stat-table-wrap">285 <table className="stat-table matrix-table">286 <thead>287 <tr>288 <th>Catégorie</th>289 {matrixSources.map((src) => (290 <th key={src} title={sourceName(src)}>291 <span className="basket-th">292 <SourceLogo source={src} size={24} fallback="hide" />293 {sourceShort(src)}294 </span>295 </th>296 ))}297 </tr>298 </thead>299 <tbody>300 {matrixRows.map(({ cat, per }) => {301 const vals = matrixSources302 .map((src) => per[src]?.median_price)303 .filter((v): v is number => v != null);304 const min = vals.length ? Math.min(...vals) : null;305 const max = vals.length ? Math.max(...vals) : null;306 return (307 <tr key={cat}>308 <td>309 <Link to={`/?category=${encodeURIComponent(cat)}`}>{cat}</Link>310 </td>311 {matrixSources.map((src) => {312 const cell = per[src];313 const v = cell?.median_price ?? null;314 let style: React.CSSProperties | undefined;315 if (v != null && min != null && max != null) {316 if (v === min) style = { background: "rgba(46, 158, 99, 0.16)" };317 else if (max > min) {318 const t = (v - min) / (max - min);319 style = { background: `rgba(232, 84, 47, ${(0.05 + 0.2 * t).toFixed(3)})` };320 }321 }322 return (323 <td key={src} style={style}324 className={v != null && v === min ? "cell-best" : ""}325 title={cell ? `${fmt(cell.n)} produits` : undefined}>326 {fmt$(v)}327 </td>328 );329 })}330 </tr>331 );332 })}333 </tbody>334 </table>335 </div>336 <div className="matrix-legend">337 <span><span className="sw" style={{ background: "rgba(46, 158, 99, 0.3)" }} />moins cher</span>338 <span><span className="sw" style={{ background: "rgba(232, 84, 47, 0.25)" }} />plus cher</span>339 </div>340 </section>341342 {/* ---- 5 · distribution des prix ---- */}343 <section className="viz-card">344 <h2>Distribution des prix</h2>345 <p className="viz-sub">produits actifs par palier de prix</p>346 <div className="hbars">347 {m.price_distribution.map((b) => (348 <div className="hbar-row" key={b.range}>349 <span className="hbar-label">{b.range}</span>350 <span className="hbar-track">351 <span className="hbar-fill" style={{ width: `${(b.n / distMax) * 100}%` }} />352 </span>353 <span className="hbar-value">{fmt(b.n)}</span>354 </div>355 ))}356 </div>357 </section>358359 {/* ---- 6 · baisses de prix (7 j) ---- */}360 <section className="viz-card">361 <h2>📉 Baisses de prix (7 jours)</h2>362 <p className="viz-sub">produits dont le prix relevé a diminué depuis la dernière synchronisation</p>363 {m.price_drops.length > 0 ? (364 <ul className="drops-list">365 {m.price_drops.slice(0, 20).map((dr, i) => (366 <li key={`${dr.uid}-${i}`}>367 <Link to={`/produit/${encodeURIComponent(dr.uid)}`}>368 <SourceLogo source={dr.source} size={22} fallback="hide" />369 <span className="drop-name">{dr.name}</span>370 <span className="drop-prices">371 <s>{fmt$(dr.old_price)}</s> → <b>{fmt$(dr.new_price)}</b>372 </span>373 <span className="drop-pct">−{dr.drop_pct.toLocaleString("fr-CA")} %</span>374 </Link>375 </li>376 ))}377 </ul>378 ) : (379 <p className="fine">380 Aucune baisse détectée encore — l'historique se construit à chaque synchronisation.381 </p>382 )}383 </section>384385 {/* ---- aubaines & journal (depuis /api/stats) ---- */}386 {d.deals.length > 0 && (387 <section className="viz-card">388 <h2>Meilleures aubaines du moment 🔥</h2>389 <p className="viz-sub">rabais relatif le plus fort, toutes bannières confondues</p>390 <div className="grid deals-grid">391 {d.deals.slice(0, 12).map((p) => (392 <ProductCard key={p.uid} p={p} />393 ))}394 </div>395 <p className="stats-foot">396 <Link to="/aubaines">Voir toutes les aubaines →</Link>397 </p>398 </section>399 )}400401 {d.recent_syncs.length > 0 && (402 <section className="viz-card">403 <h2>Dernières synchronisations</h2>404 <ul className="alertes">405 {d.recent_syncs.map((s) => (406 <li key={s.id}>407 {s.ok ? "✅" : "⚠️"} <b>{sourceName(s.source)}</b> — {fmtTs(s.ts)} ·{" "}408 {fmt(s.found)} produits trouvés, {fmt(s.added)} ajoutés,{" "}409 {fmt(s.updated)} mis à jour, {fmt(s.removed)} retirés410 {s.message ? ` — ${s.message}` : ""}411 </li>412 ))}413 </ul>414 </section>415 )}416417 <p className="stats-foot">418 Données recalculées à chaque synchronisation. Les catégories et bannières419 renvoient vers les produits filtrés correspondants.{" "}420 <Link to="/sources">Voir le registre complet des sources →</Link>421 </p>422 </div>423 );424}425