SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
4.4 KB · 113 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// components/ImmeubleBloc.tsx: "Building passport" block (detail page)5//   Everything is computed from what Rent-Ka actually observes (active AND6//   historical listings of the same building): units, median rents, turnover,7//   rent pressure. An indicator without a sufficient sample is shown as8//   "insufficient data" — never invented.9// -----------------------------------------------------------------------------10import { Immeuble, fmtPrice } from "../api";1112const NBSP = " ";1314const fmtTs = (ts: number | null | undefined): string =>15  ts16    ? new Date(ts * 1000).toLocaleDateString("en-CA", { month: "long", year: "numeric" })17    : "—";1819const ROTATION_CLS: Record<string, string> = {20  // backend classes (bilingual for migration safety)21  "low": "zi-ok", "normal": "zi-nc", "high": "zi-modere", "very high": "zi-eleve",22  "faible": "zi-ok", "normale": "zi-nc", "élevée": "zi-modere", "très élevée": "zi-eleve",23};2425export default function ImmeubleBloc({ im }: { im: Immeuble }) {26  // at least 2 grouped listings, otherwise the "passport" adds nothing27  if (!im || im.annonces_total < 2) return null;28  const rot = im.rotation;29  const pres = im.pression_loyers;30  const parCc = im.loyer_median_par_cc;3132  return (33    <section className="f-bloc f-immeuble" id="immeuble">34      <h2>Building passport</h2>35      <div className="kv">36        <div className="cell">37          <div className="k">Listings observed</div>38          <div className="v">{im.annonces_total} <small>incl. {im.annonces_actives} active</small></div>39        </div>40        <div className="cell">41          <div className="k">Estimated units</div>42          <div className="v">≥{NBSP}{im.unites_estimees}</div>43        </div>44        {im.loyer_median != null && (45          <div className="cell">46            <div className="k">Median rent (active)</div>47            <div className="v">{fmtPrice(im.loyer_median)}</div>48          </div>49        )}50        {im.pi2_median != null && (51          <div className="cell">52            <div className="k">Median $/sq ft</div>53            <div className="v">${im.pi2_median.toFixed(2)}</div>54          </div>55        )}56      </div>5758      {parCc && Object.keys(parCc).length > 0 && (59        <p className="im-parcc">60          By bedroom count:{" "}61          {Object.entries(parCc)62            .map(([cc, v]) => `${cc}${NBSP}bd ${fmtPrice(v)}`)63            .join(" · ")}64        </p>65      )}6667      <div className="im-indicateurs">68        <div className="im-indic">69          <span className="k">Unit turnover</span>{" "}70          {rot.statut === "calculated" ? (71            <>72              <span className={`zi-badge ${ROTATION_CLS[rot.classe ?? ""] ?? "zi-nc"}`}>73                {rot.classe}74              </span>{" "}75              <span className="im-detail">76                {rot.annonces_12m} listing{(rot.annonces_12m ?? 0) > 1 ? "s" : ""} over77                12 months for ≥{NBSP}{rot.unites_estimees} units78              </span>79            </>80          ) : (81            <span className="zi-badge zi-nc">insufficient data</span>82          )}83        </div>84        <div className="im-indic">85          <span className="k">Rent pressure</span>{" "}86          {pres ? (87            <>88              <span className={`zi-badge ${pres.variation_12m > 0.05 ? "zi-modere" : pres.variation_12m < -0.02 ? "zi-ok" : "zi-nc"}`}>89                {pres.variation_12m > 0 ? "+" : "−"}90                {Math.abs(Math.round(pres.variation_12m * 100))}{NBSP}% over 12 months91              </span>{" "}92              <span className="im-detail">93                median asking price: {fmtPrice(pres.mediane_12_24m)} →{" "}94                {fmtPrice(pres.mediane_12m)} ({pres.n_12_24m} vs {pres.n_12m} listings)95              </span>96            </>97          ) : (98            <span className="zi-badge zi-nc">insufficient sample</span>99          )}100        </div>101      </div>102103      <p className="fine">104        Building tracked by Rent-Ka since {fmtTs(im.premiere_observation)}105        {rot.statut === "calculated" && rot.methode ? ` — ${rot.methode}` : (106          " — indicators computed only from listings observed by Rent-Ka " +107          "syncs (not a census of the building)."108        )}109      </p>110    </section>111  );112}113