spb/ora-ka Public
Ora-Ka — cinq agrégateurs Ka, une barre de recherche hybride (exact + sémantique)
Python 80%
TypeScript 12.9%
CSS 6.8%
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// pages/Stats.tsx : statistiques du marché agrégé (totaux + répartitions)5// Les répartitions sont calculées à partir de /api/facets et /api/listings6// (échantillon trié par prix) — le backend n'expose pas d'agrégats détaillés.7// -----------------------------------------------------------------------------8import { useEffect, useMemo, useState } from "react";9import { Link } from "react-router-dom";10import {11 Facets, Listing, Stats, VpBanniere,12 fetchFacets, fetchListings, fetchSources, fetchStats,13 registerSourceNames, sourceName,14} from "../api";1516// --- Survalorisation vs Vrai-Prix : jauge divergente par bannière ------------17const VP_SCALE = 30; // la jauge couvre −30 % … +30 %18const pctPos = (d: number) => `${((Math.max(-VP_SCALE, Math.min(VP_SCALE, d)) + VP_SCALE) / (2 * VP_SCALE)) * 100}%`;1920function VpGauge({ b }: { b: VpBanniere }) {21 const tone = b.median_delta_pct > 10 ? "vp-sur" : b.median_delta_pct < 0 ? "vp-sous" : "vp-juste";22 return (23 <div className="vpg-row">24 <div className="vpg-name">25 {b.banniere}26 <em>{b.n.toLocaleString("fr-CA")} annonces estimées</em>27 </div>28 <div className="vpg-track">29 <span className="vpg-zero" />30 <span31 className="vpg-band"32 style={{ left: pctPos(Math.min(b.p25, b.p75)), width: `calc(${pctPos(Math.max(b.p25, b.p75))} - ${pctPos(Math.min(b.p25, b.p75))})` }}33 />34 <span className={`vpg-median ${tone}`} style={{ left: pctPos(b.median_delta_pct) }} />35 </div>36 <div className={`vpg-value ${tone}`}>37 {b.median_delta_pct > 0 ? "+" : ""}{b.median_delta_pct.toLocaleString("fr-CA")} %38 </div>39 <div className="vpg-split" title={`${b.pct_sous5} % sous l'estimation · ${b.pct_juste} % dans l'estimation · ${b.pct_sur10} % à +10 % et plus`}>40 <span className="vps-sous" style={{ width: `${b.pct_sous5}%` }} />41 <span className="vps-juste" style={{ width: `${b.pct_juste}%` }} />42 <span className="vps-sur" style={{ width: `${b.pct_sur10}%` }} />43 </div>44 </div>45 );46}4748function Bars({ rows, unit }: { rows: { key: string; n: number; href?: string }[]; unit?: string }) {49 const max = Math.max(1, ...rows.map((r) => r.n));50 return (51 <div className="hbars">52 {rows.map((r) => (53 <div className="hbar-row" key={r.key}>54 <div className="hbar-label">{r.href ? <Link to={r.href}>{r.key}</Link> : r.key}</div>55 <div className="hbar-track"><span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} /></div>56 <div className="hbar-value">{r.n.toLocaleString("fr-CA")}{unit ? <em> {unit}</em> : ""}</div>57 </div>58 ))}59 </div>60 );61}6263export default function StatsPage() {64 const [stats, setStats] = useState<Stats | null>(null);65 const [facets, setFacets] = useState<Facets | null>(null);66 const [sample, setSample] = useState<Listing[] | null>(null);6768 useEffect(() => {69 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});70 fetchStats().then(setStats).catch(() => {});71 fetchFacets().then(setFacets).catch(() => {});72 fetchListings({ sort: "price_asc" }, 2000, 0).then((r) => setSample(r.listings)).catch(() => {});73 }, []);7475 const byType = useMemo(() => {76 if (!sample) return [];77 const m = new Map<string, number>();78 for (const l of sample) if (l.property_type) m.set(l.property_type, (m.get(l.property_type) ?? 0) + 1);79 return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?property_type=${encodeURIComponent(key)}` }))80 .sort((a, b) => b.n - a.n).slice(0, 12);81 }, [sample]);8283 const byCity = useMemo(() => {84 if (!sample) return [];85 const m = new Map<string, number>();86 for (const l of sample) if (l.city) m.set(l.city, (m.get(l.city) ?? 0) + 1);87 return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?city=${encodeURIComponent(key)}` }))88 .sort((a, b) => b.n - a.n).slice(0, 15);89 }, [sample]);9091 const bySource = useMemo(() => {92 if (!facets) return [];93 return facets.sources.map((s) => ({ key: sourceName(s.source), n: s.n, href: `/?source=${encodeURIComponent(s.source)}` }))94 .sort((a, b) => b.n - a.n).slice(0, 15);95 }, [facets]);9697 const tiles = [98 { v: stats ? stats.total.toLocaleString("fr-CA") : "…", k: "Propriétés à vendre", hero: true },99 { v: stats ? String(stats.sources) : "…", k: "Agences agrégées" },100 { v: stats ? stats.cities.toLocaleString("fr-CA") : "…", k: "Villes couvertes" },101 { v: stats?.avg_price != null ? `${Math.round(stats.avg_price).toLocaleString("fr-CA")} $` : "…", k: "Prix moyen demandé" },102 { v: stats?.min_price != null ? `${Math.round(stats.min_price).toLocaleString("fr-CA")} $` : "…", k: "Prix minimum" },103 { v: stats?.max_price != null ? `${Math.round(stats.max_price).toLocaleString("fr-CA")} $` : "…", k: "Prix maximum" },104 ];105106 return (107 <div className="container stats-page">108 <span className="kicker">Le marché agrégé</span>109 <h1 className="stats-title">Statistiques</h1>110 <p className="sub">111 Portrait en direct des propriétés à vendre agrégées par Immo-Ka à travers toutes les112 agences connectées. Répartitions calculées sur un échantillon des annonces actives.113 </p>114115 <div className="tiles">116 {tiles.map((t) => (117 <div className={`tile ${t.hero ? "hero-tile" : ""}`} key={t.k}>118 <div className="tile-v">{t.v}</div>119 <div className="tile-k">{t.k}</div>120 </div>121 ))}122 </div>123124 <div className="viz-grid">125 <div className="viz-card">126 <h2>Par type de propriété</h2>127 <div className="viz-sub">Échantillon des annonces actives</div>128 {byType.length ? <Bars rows={byType} /> : <p className="fine">Chargement…</p>}129 </div>130 <div className="viz-card">131 <h2>Par agence</h2>132 <div className="viz-sub">Annonces actives (après déduplication)</div>133 {bySource.length ? <Bars rows={bySource} /> : <p className="fine">Chargement…</p>}134 </div>135 </div>136137 {stats?.vraiprix?.bannieres && stats.vraiprix.bannieres.length > 0 && (138 <div className="viz-card">139 <h2>Prix demandé vs valeur Vrai-Prix</h2>140 <div className="viz-sub">141 Écart médian entre le prix demandé et l'estimation indépendante Vrai-Prix, par bannière.142 La bande grise couvre la moitié centrale des annonces (P25–P75) ; le trait est la médiane.143 </div>144 <div className="vpg-axis">145 <div><span>−{VP_SCALE} %</span><span>estimation Vrai-Prix</span><span>+{VP_SCALE} %</span></div>146 </div>147 {stats.vraiprix.ensemble && <VpGauge b={stats.vraiprix.ensemble} />}148 <div className="vpg-sep" />149 {stats.vraiprix.bannieres.map((b) => <VpGauge b={b} key={b.banniere} />)}150 <div className="vpg-legend">151 <span><i className="vps-sous" /> sous l'estimation (≤ −5 %)</span>152 <span><i className="vps-juste" /> dans l'estimation</span>153 <span><i className="vps-sur" /> survalorisé (≥ +10 %)</span>154 </div>155 </div>156 )}157158 <div className="viz-card">159 <h2>Par ville</h2>160 <div className="viz-sub">Top 15 · échantillon des annonces actives</div>161 {byCity.length ? <Bars rows={byCity} /> : <p className="fine">Chargement…</p>}162 </div>163164 {stats?.recent_syncs && stats.recent_syncs.length > 0 && (165 <div className="viz-card">166 <h2>Synchronisations récentes</h2>167 <div className="viz-sub">Journal du moteur d'agrégation</div>168 <div className="viz-table">169 <table>170 <thead><tr><th>Agence</th><th>Trouvées</th><th>Ajoutées</th><th>MàJ</th><th>Retirées</th><th>Quand</th></tr></thead>171 <tbody>172 {stats.recent_syncs.slice(0, 15).map((s, i) => (173 <tr key={i}>174 <td>{sourceName(s.source)}</td>175 <td>{s.found}</td><td>{s.added}</td><td>{s.updated}</td><td>{s.removed}</td>176 <td style={{ color: "var(--ink-3)" }}>{new Date(s.ts * 1000).toLocaleString("fr-CA")}</td>177 </tr>178 ))}179 </tbody>180 </table>181 </div>182 </div>183 )}184185 <p className="stats-foot">Mise à jour automatique — chaque fiche renvoie à l'annonce originale de l'agence.</p>186 </div>187 );188}189