SPB Git

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