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%
22.8 KB · 671 lines typescript
Raw Blame History
1// -----------------------------------------------------------------------------2// Rent-Ka — Rental listings aggregator (Canada, outside Québec)3// Author: Simon-Pierre Boucher — contact@spboucher.ai4// api.ts: types + robust API client (timeout, typed errors)5// -----------------------------------------------------------------------------67export interface ListingDetails {8  inclusions?: Record<string, boolean>;9  appliances?: Record<string, boolean>;10  parking?: { available?: boolean; type?: string; included?: boolean; price?: number };11  contact?: { phone?: string; email?: string };12  ac?: boolean;13  elevator?: boolean;14  balcony?: boolean;15  pool?: boolean;16  gym?: boolean;17  laundry?: boolean;18  storage?: boolean;19  smoking?: boolean;20  floor?: number;21  price_from?: boolean;22}2324export interface Poi {25  cat: string;      // grocery, pharmacy, school, park, bus…26  name: string;27  dist_m: number;28}2930export interface Digest {31  version: number;32  texte_nettoye: string;33  en_bref: string | null;34  sections: { titre: string; texte: string }[];35  faits: {36    prix_mensuel: number | null;37    date_disponibilite: string | null;38    duree_bail_minimale_mois: number | null;39    nb_occupants_total: number | null;40    salle_de_bain: "commune" | "privee" | null;41    cuisine: "commune" | "privee" | null;42    electromenagers: string[];43    inclusions: string[];44    contraintes: string[];45    depot_mentionne: string | null;46    quartier_mentionne: string | null;47  };48  confiance: Record<string, "haute" | "faible">;49  incoherences: string[];50  completude: number;51}5253export interface Listing {54  /** KA ID personalization: set by the server (rentka/kaid.py) when the55      personal score is clear — shows "Recommended for you" */56  ka_reco?: { score: number; reasons: string[] } | null;57  uid: string;58  source: string;59  external_id: string;60  url: string;61  title: string;62  address: string;63  sector: string;64  city: string;65  province?: string | null;66  unit_type: string;67  price: number | null;68  price_label: string;69  availability: string;70  availability_date: string | null; // ISO "2026-07-01" or "now"71  area_sqft: number | null;72  pets: string | null;              // "oui" | "non" | "conditions"73  furnished: boolean | null;74  description: string;75  amenities: string[];76  details: ListingDetails;77  images: string[];78  lat: number | null;79  lng: number | null;80  bedrooms: number | null;81  poi?: Poi[];      // nearby amenities (detail page only)82  digest?: Digest | null;       // structured description (detail page only)83  price_history?: { ts: number; price: number | null }[];84  first_seen?: number;85  last_seen: number;86  updated_at: number;87  active: number;88  // fair rental value — rentka/fairvalue.py89  fv?: number | null;              // estimated value ($/month)90  fv_low?: number | null;          // low bound91  fv_high?: number | null;         // high bound92  fv_deviation?: number | null;    // (price - fv) / fv93  fv_verdict?: "sous" | "marche" | "sur" | null;94  fv_confidence?: "fort" | "moyen" | "faible" | null;95  // KA Scores (0-100, null = insufficient data / not served)96  ks_walk?: number | null;97  ks_transit?: number | null;98  ks_bike?: number | null;99  ks_calme?: number | null;100  ks_services?: number | null;101  ks_global?: number | null;102  kascores?: KaScores | null;   // full detail (detail page only)103  // rental intelligence file (detail page only)104  building_key?: string | null;105  immeuble?: Immeuble | null;   // building passport (precomputed)106  hiver?: Hiver | null;         // winter daily-life score (0-100)107}108109// --- KA Scores — in-house score family (rentka/kascores.py) -----------------110export interface KaScores {111  walk: number | null;112  transit: number | null;113  bike: number | null;114  calme: number | null;115  services: number | null;116  global: number | null;117  details: {118    walk?: { cats?: { cat: string; dist_m: number | null; pts: number }[]; bonus_choix?: number; raison?: string };119    transit?: { arret_bus_m?: number | null; station_metro_m?: number | null; pmd_percentile?: number | null; raison?: string };120    bike?: { km_cyclables_1km?: number; note?: string; raison?: string };121    calme?: { sources_bruit?: { source: string; dist_m: number | null; pen: number }[]; bonus_parc?: number; note?: string };122    services?: { familles?: Record<string, number>; raison?: string };123    labels?: Record<string, string | null>;124  };125  version: string;126  computed_at: number;127}128129export interface KaScoresStats {130  annonces: number;131  avec_score: number;132  couverture_pct: number;133  version: string;134  moyennes: Record<string, { moyenne: number | null; n: number }>;135}136137export const fetchKaScoresStats = () => get<KaScoresStats>("/api/kascores/stats");138139/** Label for a KA Score (same thresholds as rentka/kascores.py). */140export function kaLabel(score: number | null | undefined): string | null {141  if (score == null) return null;142  if (score >= 85) return "Exceptional";143  if (score >= 70) return "Excellent";144  if (score >= 55) return "Very good";145  if (score >= 40) return "Average";146  return "Low";147}148149/** Custom weights for the global KA Score (localStorage). */150export const KA_DEFAULT_WEIGHTS: Record<string, number> = {151  walk: 0.30, transit: 0.20, bike: 0.15, calme: 0.20, services: 0.15,152};153154export function kaWeights(): Record<string, number> {155  try {156    const raw = localStorage.getItem("rentka_ks_weights");157    if (!raw) return KA_DEFAULT_WEIGHTS;158    const w = JSON.parse(raw) as Record<string, number>;159    return Object.keys(KA_DEFAULT_WEIGHTS).every((k) => typeof w[k] === "number")160      ? w : KA_DEFAULT_WEIGHTS;161  } catch {162    return KA_DEFAULT_WEIGHTS;163  }164}165166/** Global KA Score recomputed with the user's weights. */167export function kaGlobal(168  s: { walk?: number | null; transit?: number | null; bike?: number | null;169       calme?: number | null; services?: number | null },170  weights: Record<string, number> = kaWeights(),171): number | null {172  let poids = 0, acquis = 0;173  for (const k of Object.keys(KA_DEFAULT_WEIGHTS)) {174    const v = (s as Record<string, number | null | undefined>)[k];175    if (v != null) { poids += weights[k]!; acquis += weights[k]! * v; }176  }177  return poids < 0.5 ? null : Math.round((acquis / poids) * 10) / 10;178}179180export interface FairValueDetail {181  uid: string;182  fv: number; fv_low: number; fv_high: number;183  deviation: number | null;184  verdict: "sous" | "marche" | "sur" | null;185  confidence: "fort" | "moyen" | "faible";186  method: string; comps: number; segment: string;187  model_version: string;188  segment_n: number;189  histogram: { x0: number; x1: number; n: number }[];190}191192export const fetchFairValue = (uid: string) =>193  get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`);194195export interface HistoriqueLouka {196  premiere_observation: number | null;197  derniere_observation: number | null;198  active: boolean;199  jours_en_ligne: number;200  prix_initial: number | null;201  prix_actuel: number | null;202  variation: number | null;203  modifications: number;204  timeline: {205    ts: number; type: string; statut: string;206    prix?: number | null; prix_avant?: number | null;207    avant?: unknown; apres?: unknown;208  }[];209  methode: string;210}211212export const fetchHistorique = (uid: string) =>213  get<HistoriqueLouka>(`/api/listings/${encodeURIComponent(uid)}/historique`);214215export interface Recyclees {216  matches: {217    uid: string; source: string; prix: number | null;218    unit_type: string | null;219    derniere_observation: number | null; premiere_observation: number | null;220    confiance: number; signaux: string[];221  }[];222  statut: string;223  methode: string;224}225226export const fetchRecyclees = (uid: string) =>227  get<Recyclees>(`/api/listings/${encodeURIComponent(uid)}/recyclees`);228229export interface Immeuble {230  bkey: string;231  address: string | null;232  city: string | null;233  computed_at: number;234  annonces_total: number;235  annonces_actives: number;236  annonces_30j: number;237  annonces_90j: number;238  annonces_12m: number;239  unites_identifiees: number;240  unites_estimees: number;241  loyer_median: number | null;242  loyer_median_par_cc: Record<string, number> | null;243  pi2_median: number | null;244  pression_loyers: {245    variation_12m: number; mediane_12m: number; mediane_12_24m: number;246    n_12m: number; n_12_24m: number; statut: string;247  } | null;248  rotation: {249    statut: string; classe?: string; ratio?: number;250    annonces_12m?: number; unites_estimees?: number;251    observation_jours?: number; methode?: string;252  };253  gestionnaires: string[];254  sources: Record<string, number>;255  premiere_observation: number;256  derniere_observation: number;257  unites_actives: { uid: string; unit_type: string | null; price: number | null; bedrooms: number | null }[];258}259260export interface Hiver {261  score: number;262  classe: string;263  detail: { critere: string; score: number; distance_m: number | null; minutes?: number; nom?: string | null; note?: string }[];264  statut: string;265  methode: string;266}267268export interface Gestionnaire {269  source_id: string;270  nom: string | null;271  site_web: string | null;272  telephone: string | null;273  annonces_actives: number;274  google_maps?: {275    statut: string;276    nom?: string; adresse?: string; note?: number | null;277    nombre_avis?: number | null; confiance_association?: number;278    signaux?: Record<string, unknown>;279    methode?: string; note_methode?: string;280  };281  avis?: {282    n: number; moyenne?: number; distribution?: Record<string, number>;283    pct_negatif?: number; pct_positif?: number;284    moyenne_12m?: number; n_12m?: number;285    tendance?: string; tendance_delta?: number;286    avec_reponse_proprietaire?: number;287    themes?: Record<string, { mentions: number; negatif: number; positif: number }>;288    plaintes_frequentes?: string[];289    methode?: string;290  } | null;291  avis_recents?: {292    note: number | null; texte: string | null; date: string | null;293    auteur: string | null; reponse_proprietaire: boolean;294    analyse: { topics?: string[]; sentiment?: string; severite?: string };295  }[];296  derniere_synchro?: number | null;297}298299export const fetchGestionnaire = (sourceId: string) =>300  get<Gestionnaire>(`/api/managers/${encodeURIComponent(sourceId)}`);301302/** 250 -> "250 m", 1240 -> "1.2 km" */303export const fmtDist = (m: number): string =>304  m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;305306export interface Facets {307  cities: string[];308  sectors: string[];309  unit_types: string[];310  provinces?: string[];311  sources: { source: string; n: number }[];312}313314export interface Source {315  id: string;316  name: string;317  url: string;318  listing_url: string;319  sectors: string;320  connector: string | null;321  status: string;322  active_listings: number;323  last_sync: number | null;324}325326/** Province codes → display names (en-CA). */327export const PROVINCE_NAMES: Record<string, string> = {328  ON: "Ontario",329  BC: "British Columbia",330  AB: "Alberta",331  SK: "Saskatchewan",332  MB: "Manitoba",333  NB: "New Brunswick",334  NS: "Nova Scotia",335  PE: "Prince Edward Island",336  NL: "Newfoundland and Labrador",337  YT: "Yukon",338  NT: "Northwest Territories",339  NU: "Nunavut",340};341342export interface Stats {343  total: number;344  provinces?: Record<string, number>;345  sources: number;346  avg_price: number | null;347}348349const SOURCE_NAMES: Record<string, string> = {};350351export function registerSourceNames(sources: Source[]) {352  for (const s of sources) SOURCE_NAMES[s.id] = s.name;353}354export function sourceName(id: string): string {355  return SOURCE_NAMES[id] ?? id;356}357358async function get<T>(path: string): Promise<T> {359  const ctrl = new AbortController();360  const timer = setTimeout(() => ctrl.abort(), 20000);361  try {362    const res = await fetch(path, { signal: ctrl.signal });363    if (!res.ok) throw new Error(`API ${res.status} — ${path}`);364    return (await res.json()) as T;365  } finally {366    clearTimeout(timer);367  }368}369370export interface ListingFilters {371  city?: string;372  sector?: string;373  province?: string;374  unit_type?: string;375  source?: string;376  price_min?: string;377  price_max?: string;378  pets?: string;          // "oui" -> accepted (yes OR conditions)379  furnished?: string;     // "1" | "0"380  available_by?: string;  // ISO: available now or before this date381  area_min?: string;      // minimum area (sq ft)382  q?: string;383  deal?: string;          // "sous" | "marche" | "sur" (fair value)384  kascore_min?: string;   // minimum global KA Score ("60" | "70" | "80")385  sort?: string;          // "deal" deals | "ka" KA Score386}387388export function fetchListings(f: ListingFilters, limit?: number, offset?: number) {389  const params = new URLSearchParams();390  for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);391  if (limit != null) params.set("limit", String(limit));392  if (offset) params.set("offset", String(offset));393  return get<{ total: number; listings: Listing[] }>(`/api/listings?${params}`);394}395396// --- Unified list + map search (/api/search) ---------------------------------397// One response = the list page + ALL map points, same sort: the list counter398// equals the number of markers by construction.399400/** Compact point: [uid, lng, lat, price, verdict ("s"|"m"|"o"|null)]. */401export type SearchPoint = [string, number, number, number | null, string | null];402403export type SearchSort = "prix" | "prix_desc" | "recent" | "deal" | "ka";404405export interface SearchQuery extends Omit<ListingFilters, "sort"> {406  bbox?: string;        // west,south,east,north — visible map area407  poly?: string;        // lng,lat;lng,lat;… — drawn area408  sort?: SearchSort;409  page?: number;410  page_size?: number;411  include?: "tout" | "liste";   // "liste": points unchanged client-side412}413414export interface SearchResponse {415  total: number;              // shared counter list = map416  page: number;417  page_size: number;418  sort: SearchSort;419  listings: Listing[];420  points: SearchPoint[] | null;   // null if include="liste"421  unpositioned: number;           // filtered listings without coordinates422}423424export async function fetchSearch(425  qy: SearchQuery,426  signal?: AbortSignal,427): Promise<SearchResponse> {428  const params = new URLSearchParams();429  for (const [k, v] of Object.entries(qy)) if (v) params.set(k, String(v));430  const res = await fetch(`/api/search?${params}`, { signal });431  if (!res.ok) throw new Error(`API ${res.status} — /api/search`);432  return (await res.json()) as SearchResponse;433}434435export interface GroupStat {436  key: string;437  count: number;438  sources?: number;439  avg_price: number | null;440  min_price: number | null;441}442443export interface DetailedStats {444  totals: {445    total: number;446    with_price: number;447    avg: number | null;448    median: number | null;449    min: number | null;450    max: number | null;451    sources: number;452    cities: number;453    regions: number;454    gps_pct: number | null;455    superficie_moyenne: number | null;456    dispo_now: number;457  };458  histogram: { lo: number; hi: number | null; count: number }[];459  by_type: GroupStat[];460  by_city: GroupStat[];461  by_source: GroupStat[];462  by_region: GroupStat[];463  offre: {464    furnished_pct: number | null;465    pets_oui_pct: number | null;466    pets_connu: number;467    chauffage_pct: number | null;468    electricite_pct: number | null;469    eau_chaude_pct: number | null;470    internet_pct: number | null;471    clim_pct: number | null;472    stationnement_pct: number | null;473    balcon_pct: number | null;474    dispo_now: number;475    dispo_date: number;476    dispo_inconnue: number;477    superficie_moyenne: number | null;478    superficie_connue: number;479    prix_pi2: { key: string; count: number; val: number }[];480  };481  baisses: { uid: string; title: string; city: string; avant: number; apres: number; pct: number }[];482  sante: { sources_sync_24h: number; alertes_24h: { source: string; message: string; ts: number }[] };483}484485export const fetchDetailedStats = () => get<DetailedStats>("/api/stats/detailed");486487export const fetchListing = (uid: string) =>488  get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);489export const fetchFacets = (city?: string) =>490  get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);491492/** ISO date at +n days (for "available within 1 month", etc.) */493export function isoInDays(n: number): string {494  const d = new Date();495  d.setDate(d.getDate() + n);496  return d.toISOString().slice(0, 10);497}498export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");499export const fetchStats = () => get<Stats>("/api/stats");500501// -- user account (Google sign-in) ---------------------------------------------502export type Socials = Partial<Record<503  "instagram" | "facebook" | "x" | "linkedin" | "tiktok" | "youtube", string>>;504505export interface Me {506  uid: number;507  ka_id: string;          // member id "ka-0123456789" (Groupe KA ecosystem)508  email: string;509  name: string;           // effective name (display_name else Google name)510  google_name: string;511  picture: string;        // effective avatar (uploaded photo else Google)512  google_picture: string;513  avatar_url: string | null;   // uploaded photo ("/uploads/avatars/…")514  display_name: string;515  bio: string;516  city: string;517  phone: string;518  website: string;519  socials: Socials;520  public: boolean;        // public profile /u/{ka_id} enabled (opt-in)521  role: "locataire" | "gestionnaire" | null;522  org_source: string | null;   // claimed source (manager)523  created_at: number | null;524  last_login: number | null;525  provider: string;526  // — profile managed at the Groupe KA HUB (source of truth: groupe-ka.com/compte) —527  profile_source?: "groupe-ka" | "local";528  role_label?: string;    // Groupe KA status (e.g. "ÉQUIPE · GROUPE KA")529  job_title?: string;530  company?: string;531  age?: number | null;532  public_url?: string;    // hub public page (if public profile)533}534535export const setRole = (role: "locataire" | "gestionnaire") =>536  fetch(`/api/me/role?role=${role}`, { method: "POST" });537538export const updateMyProfile = (fields: {539  display_name: string; bio: string; city: string;540  phone: string; website: string; socials: Socials;541}) => fetch("/api/me/profile", {542  method: "PUT",543  headers: { "Content-Type": "application/json" },544  body: JSON.stringify(fields),545});546547export async function uploadAvatar(file: File): Promise<string | null> {548  const fd = new FormData();549  fd.append("file", file);550  const res = await fetch("/api/me/avatar", { method: "POST", body: fd });551  if (!res.ok) return null;552  return ((await res.json()) as { avatar_url: string }).avatar_url;553}554export const deleteAvatar = () => fetch("/api/me/avatar", { method: "DELETE" });555556// -- saved rentals (tenant) ------------------------------------------------------557export const fetchFavorites = () =>558  get<{ uids: string[]; listings: Listing[] }>("/api/favorites");559export const addFavorite = (uid: string) =>560  fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "POST" });561export const removeFavorite = (uid: string) =>562  fetch(`/api/favorites/${encodeURIComponent(uid)}`, { method: "DELETE" });563564// -- manager page ----------------------------------------------------------------565export interface OrgProfile {566  source: string;567  name: string;568  active_listings: number;569  claimed?: boolean;570  tagline?: string;571  description?: string;572  website?: string;573  phone?: string;574  email?: string;575  logo_url?: string;576}577export const fetchMyOrg = () =>578  get<{ source: string | null; name?: string; active_listings?: number;579        profile?: Record<string, string> }>("/api/org/mine");580export const claimOrg = (source: string) =>581  fetch(`/api/org/claim?source=${encodeURIComponent(source)}`, { method: "POST" });582export const updateOrg = (fields: {583  tagline: string; description: string; website: string;584  phone: string; email: string; logo_url: string;585}) => fetch("/api/org", {586  method: "PUT",587  headers: { "Content-Type": "application/json" },588  body: JSON.stringify(fields),589});590export const fetchOrg = (sourceId: string) =>591  get<OrgProfile>(`/api/org/${encodeURIComponent(sourceId)}`);592593export interface PublicProfile {594  ka_id: string;595  name: string;596  picture: string;597  bio: string;598  city: string;599  website: string;600  socials: Socials;601  role: "locataire" | "gestionnaire" | null;602  created_at: number | null;603  // — Groupe KA HUB fields (profile_source === "groupe-ka") —604  profile_source?: "groupe-ka" | "local";605  role_label?: string;606  job_title?: string;607  company?: string;608  age?: number | null;609}610export const fetchPublicProfile = (kaId: string) =>611  get<PublicProfile>(`/api/users/${encodeURIComponent(kaId)}`);612export const setPublicProfile = (enabled: boolean) =>613  fetch(`/api/me/public?enabled=${enabled}`, { method: "POST" });614/** Signed-in profile, or null (401 = simply not signed in). */615export async function fetchMe(): Promise<Me | null> {616  try {617    const res = await fetch("/api/me");618    return res.ok ? ((await res.json()) as Me) : null;619  } catch {620    return null;621  }622}623export const fetchAuthConfig = () => get<{ google: boolean; ka: boolean }>("/api/auth/config");624export const logout = () => fetch("/api/auth/logout", { method: "POST" });625626export const fmtPrice = (p: number | null, label?: string) =>627  p != null628    ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })629    : label || "Price on request";630631/** "now" -> "Now", "2026-12-01" -> "December 1, 2026" */632export function fmtAvailability(iso: string | null): string | null {633  if (!iso) return null;634  if (iso === "now") return "Now";635  const [y, m, d] = iso.split("-").map(Number);636  if (!y || !m || !d) return null;637  return new Date(y, m - 1, d).toLocaleDateString("en-CA", {638    day: "numeric", month: "long", year: "numeric",639  });640}641642// --- Optimized images + Rent-Ka fallback visual -------------------------------643/** WebP thumbnail served at screen size (see /api/img on the API side).644 *  If the API does not know the URL yet (audit in progress), it answers 404645 *  and the component falls back to the original URL via onError. */646export const thumb = (u: string, w: 160 | 480 | 800 | 1280 = 480) =>647  `/api/img?u=${encodeURIComponent(u)}&w=${w}`;648649/** Elegant fallback visual in Rent-Ka colours (never a broken-image icon) —650 *  inline SVG with the unit type as a label. */651export function placeholderImage(label?: string): string {652  const txt = (label || "Rental").slice(0, 12);653  const svg =654    `<svg xmlns='http://www.w3.org/2000/svg' width='800' height='525' viewBox='0 0 800 525'>` +655    `<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>` +656    `<stop offset='0' stop-color='#e9efff'/><stop offset='1' stop-color='#c8d7fb'/>` +657    `</linearGradient></defs>` +658    `<rect width='800' height='525' fill='url(#g)'/>` +659    `<g transform='translate(400 235)' stroke='#2456e6' stroke-width='14' fill='none' stroke-linecap='round' stroke-linejoin='round'>` +660    `<path d='M -95 -10 L 0 -85 L 95 -10'/>` +661    `<path d='M -70 -25 L -70 70 L 70 70 L 70 -25'/>` +662    `<rect x='-18' y='14' width='36' height='56'/>` +663    `</g>` +664    `<text x='400' y='390' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +665    `font-size='30' font-weight='700' fill='#1738a8'>${txt}</text>` +666    `<text x='400' y='426' text-anchor='middle' font-family='system-ui,-apple-system,Segoe UI,sans-serif' ` +667    `font-size='19' fill='#1738a8' opacity='0.75'>Photo coming soon · Rent-Ka</text>` +668    `</svg>`;669  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;670}671