SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
8.7 KB · 64 lines typescript
Raw Blame History
1// « En bref » : constats déterministes à partir des données (jamais d'invention), inspirés de la logique de synthèse des fiches sources, réécrits pour Toveli.2import { getDb } from "./db";3import type { MarketMetricRow } from "./types";4import { fmtPrice, fmtPct, fmtInt } from "./format";56export interface Insight { tone: "good" | "warn" | "info" | "neutral"; text: string }7export interface MarketPosition { metric: MarketMetricRow | null; pricePercentile: number | null; ppsfVsMedian: number | null; daysVsMedian: number | null }89export function marketPosition(l: { market: string; category: string; city: string | null; region: string | null; price: number | null; area_sqft: number | null; first_seen: string | null }): MarketPosition {10  const db = getDb();11  const pick = (scope: string, value: string | null) => value ? (db.prepare("SELECT * FROM market_metric WHERE scope=? AND scope_value=? AND market=? AND category=?").get(scope, value, l.market, l.category) as MarketMetricRow | undefined) ?? null : null;12  const metric = pick("city", l.city) ?? pick("region", l.region) ?? pick("province", "Québec");13  let pricePercentile: number | null = null;14  if (l.price && metric) {15    const scopeCol = metric.scope === "city" ? "city" : metric.scope === "region" ? "region" : null;16    const row = db.prepare(`SELECT sum(price < ?) * 1.0 / count(*) p FROM listing WHERE market=? AND category=? AND price>0 ${scopeCol ? `AND ${scopeCol} = ?` : ""}`).get(...(scopeCol ? [l.price, l.market, l.category, metric.scope_value] : [l.price, l.market, l.category])) as { p: number | null } | undefined;17    pricePercentile = row?.p != null ? Math.round(row.p * 100) : null;18  }19  const ppsf = l.price && l.area_sqft && l.area_sqft > 100 ? l.price / l.area_sqft : null;20  const ppsfVsMedian = ppsf && metric?.median_ppsf ? ppsf / metric.median_ppsf - 1 : null;21  const days = l.first_seen ? Math.floor((Date.now() - Date.parse(l.first_seen)) / 86400000) : null;22  const daysVsMedian = days != null && metric?.median_days != null ? days - metric.median_days : null;23  return { metric, pricePercentile, ppsfVsMedian, daysVsMedian };24}2526export function insights(l: { market: string; category: string; city: string | null; price: number | null; area_sqft: number | null; lot_sqft: number | null; year_built: number | null; bedrooms: number | null; first_seen: string | null; image_count: number | null; type_label: string | null }, v: { estimate: number | null; ratio: number | null; verdict: string | null; confidence: string | null } | null, pos: MarketPosition, priceDrops: number, roleValue: number | null, score: { global: number | null; transit: number | null; walk: number | null } | null): Insight[] {27  const out: Insight[] = []; const rent = l.market === "rent"; const scopeLabel = pos.metric ? (pos.metric.scope === "province" ? "au Québec" : `à ${pos.metric.scope_value}`) : null;28  if (v?.estimate && v.ratio && l.price) {29    const gap = l.price / v.estimate - 1; const weak = v.confidence === "C" || v.confidence === "D";30    if (Math.abs(gap) < 0.05) out.push({ tone: "info", text: `${rent ? "Loyer" : "Prix"} aligné sur ${rent ? "le loyer de référence" : "l'estimation Toveli"} (${fmtPrice(v.estimate, { rent })}), écart ${fmtPct(gap)}${weak ? " — confiance faible" : ""}.` });31    else out.push({ tone: gap < 0 ? "good" : "warn", text: `${rent ? "Loyer" : "Prix demandé"} ${gap < 0 ? "inférieur" : "supérieur"} de ${fmtPct(Math.abs(gap), 1, false)} à ${rent ? "notre loyer de référence" : "notre estimation"} (${fmtPrice(v.estimate, { rent })})${v.confidence ? `, confiance ${v.confidence}` : ""}${weak ? " : à interpréter avec prudence" : ""}.` });32  } else if (!rent && l.category !== "terrain" && l.category !== "commercial") out.push({ tone: "neutral", text: "Pas d'estimation Toveli pour cette annonce (unité du rôle non appariée ou données insuffisantes)." });33  if (pos.pricePercentile != null && pos.metric && scopeLabel) out.push({ tone: "neutral", text: `${rent ? "Loyer" : "Prix"} plus élevé que ${pos.pricePercentile} % des ${l.category === "logement" ? "logements" : `${l.category}s`} ${rent ? "à louer" : "à vendre"} ${scopeLabel} (médiane ${fmtPrice(pos.metric.median_price, { rent })}, ${fmtInt(pos.metric.n)} annonces).` });34  if (pos.ppsfVsMedian != null && !rent) out.push({ tone: pos.ppsfVsMedian < -0.1 ? "good" : pos.ppsfVsMedian > 0.1 ? "warn" : "neutral", text: `${fmtInt(l.price! / l.area_sqft!)} $/pi², soit ${fmtPct(pos.ppsfVsMedian)} par rapport à la médiane ${scopeLabel} (${fmtInt(pos.metric!.median_ppsf)} $/pi²).` });35  if (pos.daysVsMedian != null && pos.metric?.median_days != null) { const d = Math.floor((Date.now() - Date.parse(l.first_seen!)) / 86400000); out.push({ tone: d <= 7 ? "good" : pos.daysVsMedian > 30 ? "warn" : "neutral", text: d <= 7 ? `Nouvelle annonce : en ligne depuis ${d} jour${d > 1 ? "s" : ""} (médiane ${pos.metric.median_days} j ${scopeLabel}).` : `En ligne depuis ${d} jours, ${pos.daysVsMedian > 0 ? `${pos.daysVsMedian} de plus` : `${-pos.daysVsMedian} de moins`} que la médiane ${scopeLabel} (${pos.metric.median_days} j).` }); }36  if (priceDrops > 0) out.push({ tone: "info", text: `${priceDrops} baisse${priceDrops > 1 ? "s" : ""} de ${rent ? "loyer" : "prix"} observée${priceDrops > 1 ? "s" : ""} depuis la première collecte.` });37  if (!rent && roleValue && l.price) out.push({ tone: "neutral", text: `Prix demandé = ${(l.price / roleValue).toFixed(2)} × la valeur au rôle (${fmtPrice(roleValue)}).` });38  if (l.year_built && l.year_built >= new Date().getFullYear() - 5) out.push({ tone: "good", text: `Construction récente (${l.year_built}).` });39  if (!rent && l.lot_sqft && l.lot_sqft >= 20000) out.push({ tone: "good", text: `Grand terrain : ${fmtInt(l.lot_sqft)} pi² (${(l.lot_sqft / 43560).toFixed(2)} acre).` });40  if (score?.global != null) out.push({ tone: score.global >= 7 ? "good" : "neutral", text: `Score de quartier ${score.global.toFixed(1)}/10${score.transit != null ? ` · transport ${score.transit.toFixed(1)}` : ""}${score.walk != null ? ` · marche ${score.walk.toFixed(1)}` : ""}.` });41  if ((l.image_count ?? 0) < 3) out.push({ tone: "warn", text: `Peu de photos (${l.image_count ?? 0}) : demandez-en davantage avant de vous déplacer.` });42  return out.slice(0, 7);43}4445/** Taxes et évaluation extraites des détails source (libellés Centris/courtiers), sans interprétation. */46export function extractFinancials(details: Record<string, unknown>): { municipalTax: number | null; schoolTax: number | null; assessment: number | null; condoFees: number | null } {47  const num = (v: unknown) => { if (v == null) return null; const n = Number(String(v).replace(/[^\d.,]/g, "").replace(/\s/g, "").replace(",", ".")); return Number.isFinite(n) && n > 0 ? n : null; };48  const find = (re: RegExp) => { for (const [k, v] of Object.entries(details)) if (re.test(k.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase())) { const n = num(v); if (n) return n; } return null; };49  return { municipalTax: find(/taxes? municipale/), schoolTax: find(/taxes? scolaire/), assessment: find(/evaluation municipale( \(total\)| totale)?$|^evaluation$/), condoFees: find(/frais de (condo|copropriete)|charges de copropriete/) };50}5152/** Budget locatif indicatif : loyer + énergie estimée si non incluse (ordre de grandeur Hydro-Québec tarif D selon superficie) + internet. */53export function rentalBudget(l: { price: number | null; area_sqft: number | null; features: string[]; details: Record<string, unknown> }): { rent: number; electricity: number | null; heatingIncluded: boolean; electricityIncluded: boolean; internet: number; total: number; notes: string[] } | null {54  if (!l.price) return null;55  const f = l.features.map((x) => x.toLowerCase()); const d = JSON.stringify(l.details).toLowerCase();56  const electricityIncluded = f.some((x) => /lectricit/.test(x) && /inclu/.test(x)) || /"electricity":\s*true/.test(d) || f.some((x) => x === "électricité incluse" || x === "electricite incluse");57  const heatingIncluded = f.some((x) => /chauff/.test(x) && /inclu/.test(x)) || /"heating":\s*true/.test(d) || f.includes("chauffage inclus") || f.includes("chauffé");58  const area = l.area_sqft && l.area_sqft > 150 ? l.area_sqft : 750;59  const electricity = electricityIncluded ? 0 : Math.round((heatingIncluded ? 0.035 : 0.11) * area + 25); // ordre de grandeur mensuel moyen annualisé60  const internet = 65;61  const notes = ["Énergie : ordre de grandeur annualisé (tarif résidentiel Hydro-Québec, chauffage électrique), varie selon l'isolation et l'usage.", "Internet : forfait moyen. Assurance locataire (~25 $/mois) et stationnement non inclus."];62  return { rent: l.price, electricity: electricityIncluded ? 0 : electricity, heatingIncluded, electricityIncluded, internet, total: l.price + (electricityIncluded ? 0 : electricity) + internet, notes };63}64