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%
7.8 KB · 253 lines typescript
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// api.ts : types + client API robuste (timeout, erreurs typées)5// -----------------------------------------------------------------------------67export interface Product {8  uid: string;9  source: string;              // id de la bannière (metro, iga, maxi…)10  external_id: string;11  url: string;                 // fiche produit chez la source12  name: string;13  brand: string;14  category: string;            // catégorie canonique Food-Ka15  category_raw: string;        // taxonomie originale de la bannière16  size_label: string;          // format affiché, ex. "500 g", "2 L"17  price: number | null;        // prix courant ($ CAD), rabais inclus18  regular_price: number | null;// prix régulier si le produit est en solde19  price_label: string;         // texte original (ex. "2 / 5,00 $")20  on_sale: boolean;21  unit_price: number | null;   // prix par unité comparable22  unit_price_label: string;    // ex. "0,70 $ / 100 g"23  in_stock: boolean | null;    // null = inconnu24  description: string;25  keywords: string[];          // tags source (bio, sans gluten…)26  details: Record<string, unknown>;27  images: string[];28  first_seen?: number;29  last_seen: number;30  updated_at: number;31  active: number;32}3334/** Fiche produit : produit + historique de prix + comparaison inter-bannières */35export interface ProductDetail extends Product {36  price_history: { ts: number; price: number | null }[];37  compare: Product[];38}3940export interface Facets {41  categories: { category: string; n: number }[];42  brands: { brand: string; n: number }[];43  sources: { source: string; n: number }[];44  on_sale: number;45}4647export interface Source {48  id: string;49  name: string;50  url: string;51  catalog_url: string;52  connector: string | null;53  status: string;54  region: string;55  tech: string;56  active_products: number;57  last_sync: number | null;58  notes?: string;59}6061export interface SyncEntry {62  id: number;63  source: string;64  ts: number;65  found: number;66  added: number;67  updated: number;68  removed: number;69  ok: number;70  message: string | null;71}7273export interface Stats {74  total: number;75  on_sale: number;76  sources: number;77  categories: number;78  avg_price: number | null;79  by_source: { source: string; n: number; sales: number; avg_price: number | null }[];80  by_category: { category: string; n: number; avg_price: number | null }[];81  deals: Product[];82  recent_syncs: SyncEntry[];83}8485// --- Agrégats détaillés du marché (GET /api/stats/detailed) -------------------86export interface SourceMarketStats {87  source: string;88  n: number;89  sales: number;90  sale_share: number;              // 0..191  avg_price: number | null;92  median_price: number | null;93  avg_discount_pct: number | null; // % moyen des rabais en cours94  max_discount_pct: number | null;95  with_unit_price: number;96  no_price: number;97}9899export interface MatrixCell { median_price: number | null; n: number; }100101export interface BasketItem {102  item: string;                            // ex. « Lait », « Œufs »103  by_source: Record<string, MatrixCell>;   // prix médian par bannière104}105106export interface BasketTotal { source: string; items: number; total: number; }107108export interface PriceDrop {109  uid: string;110  name: string;111  source: string;112  old_price: number;113  new_price: number;114  drop_pct: number;115}116117export interface DetailedStats {118  global: {119    total: number;120    on_sale: number;121    sale_share: number;            // 0..1122    sources: number;123    categories: number;124    brands: number;125    avg_price: number | null;126    median_price: number | null;127    price_changes_7d: number;128  };129  by_source: SourceMarketStats[];130  category_matrix: Record<string, Record<string, MatrixCell>>;131  basket: BasketItem[];132  basket_totals: BasketTotal[];    // triés du panier le moins cher au plus cher133  price_drops: PriceDrop[];134  price_distribution: { range: string; n: number }[];135}136137// --- Noms d'affichage des bannières ------------------------------------------138const SOURCE_NAMES: Record<string, string> = {139  metro: "Metro",140  superc: "Super C",141  iga: "IGA",142  provigo: "Provigo",143  maxi: "Maxi",144  walmart: "Walmart",145  adonis: "Marché Adonis",146  avril: "Avril",147  rachelle_bery: "Rachelle-Béry",148  pa: "PA Supermarché",149  mayrand: "Mayrand",150  tau: "Marché Tau",151  giant_tiger: "Giant Tiger",152  club_entrepot: "Club Entrepôt",153  loco: "LOCO",154  frenco: "Frenco",155  akhavan: "Akhavan",156  tt: "T&T Supermarket",157};158159export function registerSourceNames(sources: Source[]) {160  for (const s of sources) SOURCE_NAMES[s.id] = s.name;161}162163/** Nom d'affichage d'une bannière — id inconnu : joliment reformaté. */164export function sourceName(id: string): string {165  if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];166  return id167    .split(/[_-]/)168    .filter(Boolean)169    .map((w) => w.charAt(0).toUpperCase() + w.slice(1))170    .join(" ");171}172173// Noms courts — à côté des logos (cartes, tableaux serrés)174const SOURCE_SHORT: Record<string, string> = {175  adonis: "Adonis",176  pa: "PA",177  giant_tiger: "G. Tiger",178  boite_a_grains: "B. à grains",179  aliments_merci: "Merci",180  bocoboco: "BocoBoco",181  epipresto: "ÉpiPresto",182  rachelle_bery: "Rachelle-Béry",183  tau: "Tau",184  maturin: "Maturin",185  club_entrepot: "Club Ent.",186  tt: "T&T",187};188189/** Nom court d'une bannière (logo + libellé compact). */190export const sourceShort = (id: string): string =>191  SOURCE_SHORT[id] ?? sourceName(id);192193async function get<T>(path: string): Promise<T> {194  const ctrl = new AbortController();195  const timer = setTimeout(() => ctrl.abort(), 20000);196  try {197    const res = await fetch(path, { signal: ctrl.signal });198    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);199    return (await res.json()) as T;200  } finally {201    clearTimeout(timer);202  }203}204205export type SortKey =206  | "price_asc" | "price_desc" | "unit_price" | "discount" | "name" | "recent";207208export interface ProductFilters {209  category?: string;210  source?: string;211  brand?: string;212  price_min?: string;213  price_max?: string;214  on_sale?: string;   // "1" = en solde seulement215  q?: string;216  sort?: string;      // SortKey217  limit?: string;218  offset?: string;219}220221export function fetchProducts(f: ProductFilters) {222  const params = new URLSearchParams();223  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);224  return get<{ total: number; count: number; products: Product[] }>(225    `/api/products?${params}`226  );227}228229export const fetchProduct = (uid: string) =>230  get<ProductDetail>(`/api/products/${encodeURIComponent(uid)}`);231export const fetchFacets = (category?: string) =>232  get<Facets>(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`);233export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");234export const fetchStats = () => get<Stats>("/api/stats");235export const statsDetailed = () => get<DetailedStats>("/api/stats/detailed");236237// --- Formats ------------------------------------------------------------------238/** 4.99 -> « 4,99 $ » (les prix d'épicerie gardent les cents) */239export const fmtPrice = (p: number | null | undefined, label?: string) =>240  p != null241    ? p.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $"242    : label || "Prix non affiché";243244/** Rabais relatif en % (positif), ou null si non applicable. */245export function discountPct(p: Product): number | null {246  if (p.price == null || p.regular_price == null || p.regular_price <= p.price) return null;247  return Math.round(((p.regular_price - p.price) / p.regular_price) * 100);248}249250/** Timestamp Unix -> « 12 août 2026, 13 h 05 » */251export const fmtTs = (ts: number | null | undefined) =>252  ts ? new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) : "—";253