// ----------------------------------------------------------------------------- // Food-Ka — Agrégateur de produits d'épicerie (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // api.ts : types + client API robuste (timeout, erreurs typées) // ----------------------------------------------------------------------------- export interface Product { uid: string; source: string; // id de la bannière (metro, iga, maxi…) external_id: string; url: string; // fiche produit chez la source name: string; brand: string; category: string; // catégorie canonique Food-Ka category_raw: string; // taxonomie originale de la bannière size_label: string; // format affiché, ex. "500 g", "2 L" price: number | null; // prix courant ($ CAD), rabais inclus regular_price: number | null;// prix régulier si le produit est en solde price_label: string; // texte original (ex. "2 / 5,00 $") on_sale: boolean; unit_price: number | null; // prix par unité comparable unit_price_label: string; // ex. "0,70 $ / 100 g" in_stock: boolean | null; // null = inconnu description: string; keywords: string[]; // tags source (bio, sans gluten…) details: Record; images: string[]; first_seen?: number; last_seen: number; updated_at: number; active: number; } /** Fiche produit : produit + historique de prix + comparaison inter-bannières */ export interface ProductDetail extends Product { price_history: { ts: number; price: number | null }[]; compare: Product[]; } export interface Facets { categories: { category: string; n: number }[]; brands: { brand: string; n: number }[]; sources: { source: string; n: number }[]; on_sale: number; } export interface Source { id: string; name: string; url: string; catalog_url: string; connector: string | null; status: string; region: string; tech: string; active_products: number; last_sync: number | null; notes?: string; } export interface SyncEntry { id: number; source: string; ts: number; found: number; added: number; updated: number; removed: number; ok: number; message: string | null; } export interface Stats { total: number; on_sale: number; sources: number; categories: number; avg_price: number | null; by_source: { source: string; n: number; sales: number; avg_price: number | null }[]; by_category: { category: string; n: number; avg_price: number | null }[]; deals: Product[]; recent_syncs: SyncEntry[]; } // --- Agrégats détaillés du marché (GET /api/stats/detailed) ------------------- export interface SourceMarketStats { source: string; n: number; sales: number; sale_share: number; // 0..1 avg_price: number | null; median_price: number | null; avg_discount_pct: number | null; // % moyen des rabais en cours max_discount_pct: number | null; with_unit_price: number; no_price: number; } export interface MatrixCell { median_price: number | null; n: number; } export interface BasketItem { item: string; // ex. « Lait », « Œufs » by_source: Record; // prix médian par bannière } export interface BasketTotal { source: string; items: number; total: number; } export interface PriceDrop { uid: string; name: string; source: string; old_price: number; new_price: number; drop_pct: number; } export interface DetailedStats { global: { total: number; on_sale: number; sale_share: number; // 0..1 sources: number; categories: number; brands: number; avg_price: number | null; median_price: number | null; price_changes_7d: number; }; by_source: SourceMarketStats[]; category_matrix: Record>; basket: BasketItem[]; basket_totals: BasketTotal[]; // triés du panier le moins cher au plus cher price_drops: PriceDrop[]; price_distribution: { range: string; n: number }[]; } // --- Noms d'affichage des bannières ------------------------------------------ const SOURCE_NAMES: Record = { metro: "Metro", superc: "Super C", iga: "IGA", provigo: "Provigo", maxi: "Maxi", walmart: "Walmart", adonis: "Marché Adonis", avril: "Avril", rachelle_bery: "Rachelle-Béry", pa: "PA Supermarché", mayrand: "Mayrand", tau: "Marché Tau", giant_tiger: "Giant Tiger", club_entrepot: "Club Entrepôt", loco: "LOCO", frenco: "Frenco", akhavan: "Akhavan", tt: "T&T Supermarket", }; export function registerSourceNames(sources: Source[]) { for (const s of sources) SOURCE_NAMES[s.id] = s.name; } /** Nom d'affichage d'une bannière — id inconnu : joliment reformaté. */ export function sourceName(id: string): string { if (SOURCE_NAMES[id]) return SOURCE_NAMES[id]; return id .split(/[_-]/) .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } // Noms courts — à côté des logos (cartes, tableaux serrés) const SOURCE_SHORT: Record = { adonis: "Adonis", pa: "PA", giant_tiger: "G. Tiger", boite_a_grains: "B. à grains", aliments_merci: "Merci", bocoboco: "BocoBoco", epipresto: "ÉpiPresto", rachelle_bery: "Rachelle-Béry", tau: "Tau", maturin: "Maturin", club_entrepot: "Club Ent.", tt: "T&T", }; /** Nom court d'une bannière (logo + libellé compact). */ export const sourceShort = (id: string): string => SOURCE_SHORT[id] ?? sourceName(id); async function get(path: string): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 20000); try { const res = await fetch(path, { signal: ctrl.signal }); if (!res.ok) throw new Error(`API ${res.status} — ${path}`); return (await res.json()) as T; } finally { clearTimeout(timer); } } export type SortKey = | "price_asc" | "price_desc" | "unit_price" | "discount" | "name" | "recent"; export interface ProductFilters { category?: string; source?: string; brand?: string; price_min?: string; price_max?: string; on_sale?: string; // "1" = en solde seulement q?: string; sort?: string; // SortKey limit?: string; offset?: string; } export function fetchProducts(f: ProductFilters) { const params = new URLSearchParams(); for (const [k, v] of Object.entries(f)) if (v) params.set(k, v); return get<{ total: number; count: number; products: Product[] }>( `/api/products?${params}` ); } export const fetchProduct = (uid: string) => get(`/api/products/${encodeURIComponent(uid)}`); export const fetchFacets = (category?: string) => get(`/api/facets${category ? `?category=${encodeURIComponent(category)}` : ""}`); export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); export const fetchStats = () => get("/api/stats"); export const statsDetailed = () => get("/api/stats/detailed"); // --- Formats ------------------------------------------------------------------ /** 4.99 -> « 4,99 $ » (les prix d'épicerie gardent les cents) */ export const fmtPrice = (p: number | null | undefined, label?: string) => p != null ? p.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " $" : label || "Prix non affiché"; /** Rabais relatif en % (positif), ou null si non applicable. */ export function discountPct(p: Product): number | null { if (p.price == null || p.regular_price == null || p.regular_price <= p.price) return null; return Math.round(((p.regular_price - p.price) / p.regular_price) * 100); } /** Timestamp Unix -> « 12 août 2026, 13 h 05 » */ export const fmtTs = (ts: number | null | undefined) => ts ? new Date(ts * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) : "—";