// -----------------------------------------------------------------------------
// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
// pages/Stats.tsx : statistiques du marché agrégé (totaux + répartitions)
// Les répartitions sont calculées à partir de /api/facets et /api/listings
// (échantillon trié par prix) — le backend n'expose pas d'agrégats détaillés.
// -----------------------------------------------------------------------------
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
Facets, Listing, Stats, VpBanniere,
fetchFacets, fetchListings, fetchSources, fetchStats,
registerSourceNames, sourceName,
} from "../api";
// --- Survalorisation vs Vrai-Prix : jauge divergente par bannière ------------
const VP_SCALE = 30; // la jauge couvre −30 % … +30 %
const pctPos = (d: number) => `${((Math.max(-VP_SCALE, Math.min(VP_SCALE, d)) + VP_SCALE) / (2 * VP_SCALE)) * 100}%`;
function VpGauge({ b }: { b: VpBanniere }) {
const tone = b.median_delta_pct > 10 ? "vp-sur" : b.median_delta_pct < 0 ? "vp-sous" : "vp-juste";
return (
{b.banniere}
{b.n.toLocaleString("fr-CA")} annonces estimées
{b.median_delta_pct > 0 ? "+" : ""}{b.median_delta_pct.toLocaleString("fr-CA")} %
);
}
function Bars({ rows, unit }: { rows: { key: string; n: number; href?: string }[]; unit?: string }) {
const max = Math.max(1, ...rows.map((r) => r.n));
return (
{rows.map((r) => (
{r.href ? {r.key} : r.key}
{r.n.toLocaleString("fr-CA")}{unit ? {unit} : ""}
))}
);
}
export default function StatsPage() {
const [stats, setStats] = useState(null);
const [facets, setFacets] = useState(null);
const [sample, setSample] = useState(null);
useEffect(() => {
fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
fetchStats().then(setStats).catch(() => {});
fetchFacets().then(setFacets).catch(() => {});
fetchListings({ sort: "price_asc" }, 2000, 0).then((r) => setSample(r.listings)).catch(() => {});
}, []);
const byType = useMemo(() => {
if (!sample) return [];
const m = new Map();
for (const l of sample) if (l.property_type) m.set(l.property_type, (m.get(l.property_type) ?? 0) + 1);
return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?property_type=${encodeURIComponent(key)}` }))
.sort((a, b) => b.n - a.n).slice(0, 12);
}, [sample]);
const byCity = useMemo(() => {
if (!sample) return [];
const m = new Map();
for (const l of sample) if (l.city) m.set(l.city, (m.get(l.city) ?? 0) + 1);
return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?city=${encodeURIComponent(key)}` }))
.sort((a, b) => b.n - a.n).slice(0, 15);
}, [sample]);
const bySource = useMemo(() => {
if (!facets) return [];
return facets.sources.map((s) => ({ key: sourceName(s.source), n: s.n, href: `/?source=${encodeURIComponent(s.source)}` }))
.sort((a, b) => b.n - a.n).slice(0, 15);
}, [facets]);
const tiles = [
{ v: stats ? stats.total.toLocaleString("fr-CA") : "…", k: "Propriétés à vendre", hero: true },
{ v: stats ? String(stats.sources) : "…", k: "Agences agrégées" },
{ v: stats ? stats.cities.toLocaleString("fr-CA") : "…", k: "Villes couvertes" },
{ v: stats?.avg_price != null ? `${Math.round(stats.avg_price).toLocaleString("fr-CA")} $` : "…", k: "Prix moyen demandé" },
{ v: stats?.min_price != null ? `${Math.round(stats.min_price).toLocaleString("fr-CA")} $` : "…", k: "Prix minimum" },
{ v: stats?.max_price != null ? `${Math.round(stats.max_price).toLocaleString("fr-CA")} $` : "…", k: "Prix maximum" },
];
return (
Le marché agrégé
Statistiques
Portrait en direct des propriétés à vendre agrégées par Immo-Ka à travers toutes les
agences connectées. Répartitions calculées sur un échantillon des annonces actives.
Par type de propriété
Échantillon des annonces actives
{byType.length ?
:
Chargement…
}
Par agence
Annonces actives (après déduplication)
{bySource.length ?
:
Chargement…
}
{stats?.vraiprix?.bannieres && stats.vraiprix.bannieres.length > 0 && (
Prix demandé vs valeur Vrai-Prix
Écart médian entre le prix demandé et l'estimation indépendante Vrai-Prix, par bannière.
La bande grise couvre la moitié centrale des annonces (P25–P75) ; le trait est la médiane.
−{VP_SCALE} % estimation Vrai-Prix +{VP_SCALE} %
{stats.vraiprix.ensemble &&
}
{stats.vraiprix.bannieres.map((b) =>
)}
sous l'estimation (≤ −5 %)
dans l'estimation
survalorisé (≥ +10 %)
)}
Par ville
Top 15 · échantillon des annonces actives
{byCity.length ?
:
Chargement…
}
{stats?.recent_syncs && stats.recent_syncs.length > 0 && (
Synchronisations récentes
Journal du moteur d'agrégation
Agence Trouvées Ajoutées MàJ Retirées Quand
{stats.recent_syncs.slice(0, 15).map((s, i) => (
{sourceName(s.source)}
{s.found} {s.added} {s.updated} {s.removed}
{new Date(s.ts * 1000).toLocaleString("fr-CA")}
))}
)}
Mise à jour automatique — chaque fiche renvoie à l'annonce originale de l'agence.
);
}