SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
3 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
9.6 KB · 56 lines tsx
Raw Blame History
1import type { Metadata } from "next";2import Link from "next/link";3import { cityMetrics, marketIndex, marketMetrics, regionMetrics } from "@/lib/queries";4import { dbAvailable, meta } from "@/lib/db";5import { fmtPrice, fmtInt, fmtPct, fmtDate } from "@/lib/format";6import { CATEGORY_LABEL } from "@/lib/normalize";7import { Empty, Notice, Stat } from "@/components/ui";8import { IndexChart } from "@/components/price-chart";9export const dynamic = "force-dynamic";10export const metadata: Metadata = { title: "Marché et données", description: "Prix médians, nouvelles inscriptions, jours sur le marché et écart estimation/prix par région et municipalité du Québec." };11export default async function Marche({ searchParams }: { searchParams: Promise<{ marche?: string; region?: string; ville?: string; type?: string }> }) {12  const sp = await searchParams; if (!dbAvailable()) return <div className="mx-auto max-w-6xl px-4 py-10"><Empty title="Données en cours de chargement" /></div>;13  const market = sp.marche === "location" ? "rent" : "sale"; const cat = sp.type ?? "all";14  const scope = sp.ville ? "city" : sp.region ? "region" : "province"; const value = sp.ville ?? sp.region ?? "Québec";15  const metrics = marketMetrics(scope, value, market); const all = metrics.find((m) => m.category === "all") ?? metrics[0];16  const regions = regionMetrics(market, cat); const cities = sp.region ? cityMetrics(market, cat, 300).filter((c) => regions.length && true) : cityMetrics(market, cat, 30);17  const idx = market === "sale" ? marketIndex(cat === "condo" ? "condo" : cat === "plex" ? "plex" : "unifamilial") : [];18  const vp = (() => { try { return JSON.parse(meta("vp_marche_stats") ?? "{}"); } catch { return {}; } })() as { global?: Record<string, number>; generated?: string; methods?: { method: string; mdape: number; medianRatio: number; n: number }[] };19  const qs = (patch: Record<string, string | undefined>) => { const u = new URLSearchParams(); const o = { marche: sp.marche, region: sp.region, ville: sp.ville, type: sp.type, ...patch }; for (const [k, v] of Object.entries(o)) if (v) u.set(k, v); return `/marche?${u}`; };20  return (21    <div className="mx-auto max-w-6xl px-4 py-8">22      <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight">Marché et données</h1>23      <p className="mt-1 text-ink-2">{value}{scope !== "province" ? "" : " (ensemble des annonces Toveli)"} · {market === "rent" ? "location" : "achat"} · mis à jour le {fmtDate(meta("built_at"))}</p>24      <div className="mt-4 flex gap-2 flex-wrap">25        <div className="inline-flex rounded-full border border-line p-0.5 bg-white"><Link href={qs({ marche: "achat", type: undefined })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${market === "sale" ? "bg-brand text-white" : "text-ink-2"}`}>Achat</Link><Link href={qs({ marche: "location", type: undefined })} className={`px-3 py-1.5 rounded-full text-sm font-semibold ${market === "rent" ? "bg-brand text-white" : "text-ink-2"}`}>Location</Link></div>26        {(market === "sale" ? ["all", "maison", "condo", "plex", "terrain"] : ["all", "logement", "condo", "maison"]).map((c) => <Link key={c} href={qs({ type: c === "all" ? undefined : c })} className="tv-chip" data-active={cat === c}>{c === "all" ? "Tous types" : CATEGORY_LABEL[c]}</Link>)}27        {(sp.region || sp.ville) && <Link href={qs({ region: undefined, ville: undefined })} className="tv-chip !text-ink-3">× Tout le Québec</Link>}28      </div>29      {all ? (30        <div className="mt-5 grid grid-cols-2 md:grid-cols-4 gap-3">31          <Stat label={market === "rent" ? "Loyer médian demandé" : "Prix médian demandé"} value={fmtPrice(all.median_price)} sub={`P25 ${fmtPrice(all.p25_price, { compact: true })} · P75 ${fmtPrice(all.p75_price, { compact: true })}`} />32          <Stat label="Annonces actives" value={fmtInt(all.n)} sub={`${fmtInt(all.new_30d)} nouvelles en 30 jours`} />33          <Stat label="Jours sur le marché (médiane)" value={all.median_days != null ? `${all.median_days} j` : "—"} sub="depuis la première collecte" />34          {market === "sale" ? <Stat label="Prix médian au pi²" value={all.median_ppsf ? `${fmtInt(all.median_ppsf)} $/pi²` : "—"} sub="annonces avec superficie connue" /> : <Stat label="Écart loyer / référence" value={all.median_ratio ? fmtPct(1 / all.median_ratio - 1) : "—"} sub="médiane, modèle de loyer de référence" />}35        </div>36      ) : <Notice>Pas assez d'annonces pour calculer des statistiques fiables sur ce périmètre (minimum 5).</Notice>}37      {metrics.length > 1 && (38        <section className="mt-6"><h2 className="font-bold text-lg mb-2">Par catégorie</h2><div className="overflow-x-auto tv-card"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Catégorie</th><th className="p-2 font-medium text-right">Annonces</th><th className="p-2 font-medium text-right">Médiane</th><th className="p-2 font-medium text-right">P25–P75</th><th className="p-2 font-medium text-right">Jours</th>{market === "sale" && <th className="p-2 font-medium text-right">Estim./prix</th>}</tr></thead><tbody>{metrics.filter((m) => m.category !== "all").map((m) => <tr key={m.category} className="border-t border-line"><td className="p-2 font-medium">{CATEGORY_LABEL[m.category] ?? m.category}</td><td className="p-2 text-right tabular-nums">{fmtInt(m.n)}</td><td className="p-2 text-right tabular-nums font-semibold">{fmtPrice(m.median_price)}</td><td className="p-2 text-right tabular-nums text-ink-2">{fmtPrice(m.p25_price, { compact: true })} – {fmtPrice(m.p75_price, { compact: true })}</td><td className="p-2 text-right">{m.median_days ?? "—"}</td>{market === "sale" && <td className="p-2 text-right tabular-nums">{m.median_ratio ? fmtPct(m.median_ratio - 1) : "—"}</td>}</tr>)}</tbody></table></div></section>39      )}40      {idx.length > 1 && <section className="mt-8"><h2 className="font-bold text-lg mb-2">Indice de prix des ventes ({cat === "condo" ? "condos" : cat === "plex" ? "plex" : "unifamiliales"})</h2><IndexChart points={idx} /><p className="text-xs text-ink-3 mt-1">Base 100 = {idx[idx.length - 1].month}. Variation sur 12 mois : <strong>{idx.length > 12 ? fmtPct(idx[idx.length - 1].idx / idx[idx.length - 13].idx - 1) : "—"}</strong> · sur 5 ans : <strong>{idx.length > 60 ? fmtPct(idx[idx.length - 1].idx / idx[idx.length - 61].idx - 1) : "—"}</strong>. Source : base de transactions Toveli (prix au m² médian mensuel).</p></section>}41      <section className="mt-8 grid md:grid-cols-2 gap-6">42        <div><h2 className="font-bold text-lg mb-2">Régions</h2><ul className="tv-card divide-y divide-line text-sm">{regions.map((r) => <li key={r.scope_value}><Link href={qs({ region: r.scope_value, ville: undefined })} className={`flex justify-between gap-3 p-2.5 hover:bg-surface-2 ${sp.region === r.scope_value ? "bg-brand-soft" : ""}`}><span className="font-medium">{r.scope_value}</span><span className="text-right tabular-nums">{fmtPrice(r.median_price, { compact: true })}<span className="text-xs text-ink-3"> · {fmtInt(r.n)}</span></span></Link></li>)}</ul></div>43        <div><h2 className="font-bold text-lg mb-2">Municipalités{sp.region ? ` — ${sp.region}` : " les plus actives"}</h2><ul className="tv-card divide-y divide-line text-sm max-h-[560px] overflow-auto">{cities.map((c) => <li key={c.scope_value}><Link href={qs({ ville: c.scope_value })} className={`flex justify-between gap-3 p-2.5 hover:bg-surface-2 ${sp.ville === c.scope_value ? "bg-brand-soft" : ""}`}><span className="font-medium">{c.scope_value}</span><span className="text-right tabular-nums">{fmtPrice(c.median_price, { compact: true })}<span className="text-xs text-ink-3"> · {fmtInt(c.n)}</span></span></Link></li>)}</ul></div>44      </section>45      {market === "sale" && vp.global && (46        <section className="mt-8"><h2 className="font-bold text-lg mb-2">Qualité des estimations (rétro-test sur les annonces actives)</h2>47          <div className="grid grid-cols-2 md:grid-cols-4 gap-3"><Stat label="Annonces évaluées" value={fmtInt(vp.global.n)} /><Stat label="Ratio médian estim./prix" value={vp.global.medianRatio?.toFixed(3)} /><Stat label="MdAPE" value={fmtPct(vp.global.mdape, 1, false)} sub="écart absolu médian vs prix demandé" /><Stat label="Dans P10–P90" value={fmtPct(vp.global.inP10P90, 0, false)} sub="couverture de la fourchette" /></div>48          {vp.methods && <div className="overflow-x-auto tv-card mt-3"><table className="w-full text-sm"><thead className="text-xs text-ink-3 text-left"><tr><th className="p-2 font-medium">Méthode</th><th className="p-2 font-medium text-right">n</th><th className="p-2 font-medium text-right">MdAPE</th><th className="p-2 font-medium text-right">Ratio médian</th></tr></thead><tbody>{vp.methods.map((m) => <tr key={m.method} className="border-t border-line"><td className="p-2">{m.method}</td><td className="p-2 text-right">{fmtInt(m.n)}</td><td className="p-2 text-right">{fmtPct(m.mdape, 1, false)}</td><td className="p-2 text-right">{m.medianRatio?.toFixed(3)}</td></tr>)}</tbody></table></div>}49          <p className="text-xs text-ink-3 mt-2">Le prix demandé n'est pas le prix de vente : ces écarts mesurent la cohérence estimation/annonce, pas l'erreur finale. Rétro-test généré le {fmtDate(vp.generated)}.</p>50        </section>51      )}52      <p className="mt-6 text-sm"><Link href={`/recherche?marche=${market === "rent" ? "location" : "achat"}${sp.ville ? `&ville=${encodeURIComponent(sp.ville)}` : sp.region ? `&region=${encodeURIComponent(sp.region)}` : ""}`} className="font-semibold text-brand-dark hover:underline">Voir les annonces de ce périmètre →</Link></p>53    </div>54  );55}56