SPB Git

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%
17.4 KB · 425 lines tsx
Raw Blame History
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