SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

Connecteurs enrichis : champs riches /api/stats captés et affichés

- live.ts : avg_price (Lou/Immo/Auto/Food), géo Lou·Ka, salaires+remote+top villes
  Job·Ka, croissance/médiane/modèle Vrai-Prix, indexed_last_hour/frontier/
  embedding_coverage Trouve·Ka, tiers+niches Créa·Ka, bloc vraiprix d'Immo·Ka
  (écart prix demandé vs évaluation municipale), santé des syncs (recent_syncs/
  sync_log) — repli figé champ par champ, jamais de rendu cassé
- Sorti·Ka : constante SORTIKA_CONNECTORS=8 (TODO : lire le champ quand publié)
  au lieu du « 4 » codé en dur ; ticker plex daté (ValoPlex sans /api/stats)
- Accueil : chips enrichies (loyer/prix/salaire moyens, niches, croissance),
  carte « santé des syncs », insight Immo·Ka × Vrai-Prix, file de crawl et
  couverture sémantique Trouve·Ka
- /stats : section « répartitions » (breakdowns + geo du SPEC) des deux plus
  grosses plateformes jointes ; ecostats.ts typé breakdowns/geo
- Ticker : loyer/salaire/prix moyens, +43,1 % 2021→2026, pages/heure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 45c07fb

5 changed files +481 −28

modified src/app/layout.tsx +9 −5
@@ -2,7 +2,7 @@
2 2 import type { Metadata } from "next";
3 3 import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google";
4 4 import UserButton from "./UserButton";
5 −import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live";
5 +import { getLiveMetrics, fmtDec, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live";
6 6 import "./globals.css";
7 7
8 8 const spaceGrotesk = Space_Grotesk({
@@ -58,19 +58,23 @@ const NAV_ITEMS = [
58 58 const tickerItems = (m: LiveMetrics): string[] => [
59 59 "·Ka = agréger — mille sites, un seul endroit",
60 60 `Trouve·Ka cherche le Québec — ${fmtPlus(m.trouveka.pages)} pages du web d'ici indexées`,
61 + `Trouve·Ka indexe ${fmtInt(m.trouveka.indexedLastHour)} pages à l'heure — le robot ne dort pas`,
61 62 "Dix agrégateurs · une obsession : la transparence",
62 63 `${fmtInt(m.totals.connectors)}+ connecteurs lisent les sources jour et nuit`,
63 − `${fmtPlus(m.louka.total)} logements à louer agrégés en continu`,
64 + `${fmtPlus(m.louka.total)} logements à louer agrégés en continu — loyer moyen ${fmtInt(m.louka.avgPrice)} $`,
64 65 `${fmtPlus(m.immoka.total)} propriétés à vendre, lues à la source`,
65 − `${fmtPlus(m.autoka.total)} voitures usagées chez ${m.autoka.sources} concessionnaires`,
66 + `Valeur résidentielle du Québec : +${fmtDec(m.vraiprix.growthPct)} % de 2021 à 2026 — vrai-prix.com`,
67 + `${fmtPlus(m.autoka.total)} voitures usagées chez ${m.autoka.sources} concessionnaires — prix moyen ${fmtInt(m.autoka.avgPrice)} $`,
66 68 `${fmtPlus(m.fabrika.products)} produits québécois de ${fmtInt(m.fabrika.stores)} boutiques`,
67 69 `${fmtPlus(m.foodka.total)} prix d'épicerie suivis chez ${m.foodka.sources} bannières`,
68 70 `${fmtPlus(m.restoka.items)} plats et prix réels chez ${fmtPlus(m.restoka.restaurants)} restos — resto-ka.com`,
69 71 `${fmtPlus(m.sortika.active)} sorties et événements dans les 17 régions — sorti-ka.com`,
70 72 `${fmtPlus(m.creaka.creators)} créateurs québécois, tous leurs liens regroupés — crea-ka.com`,
71 − `${fmtPlus(m.jobka.total)} offres d'emploi chez ${m.jobka.employers} employeurs — job-ka.com`,
73 + `${fmtPlus(m.jobka.total)} offres d'emploi chez ${m.jobka.employers} employeurs — salaire moyen ${fmtInt(m.jobka.avgSalaryYear)} $/an`,
72 74 `${fmtInt(m.vraiprix.units)} propriétés évaluées au Québec`,
73 − "393 867 plex · 1 733 744 portes",
75 + // ValoPlex n'expose pas de /api/stats (vérifié 2026-08-18) : chiffres du
76 + // parc au rôle foncier 2026, datés plutôt qu'inventés.
77 + "393 867 plex · 1 733 744 portes au rôle foncier 2026 — valoplex.com",
74 78 "Ka2 & Ka4 cartographient le web québécois — ka2.bot · ka4.bot",
75 79 "Zéro boîte noire — tout est automatisé, rien n'est inventé",
76 80 ];
modified src/app/page.tsx +115 −4
@@ -8,7 +8,14 @@ import {
8 8 ConnectorMatrix,
9 9 CoverageTiles,
10 10 } from "./figures";
11 −import { getLiveMetrics, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live";
11 +import {
12 + getLiveMetrics,
13 + fmtDec,
14 + fmtInt,
15 + fmtPlus,
16 + SORTIKA_CONNECTORS,
17 + type LiveMetrics,
18 +} from "@/lib/live";
12 19
13 20 // Métriques live re-mesurées au plus toutes les 10 minutes (ISR).
14 21 export const revalidate = 600;
@@ -566,17 +573,20 @@ function liveChips(m: LiveMetrics): Record<string, string[]> {
566 573 "www.lou-ka.com": [
567 574 `${fmtPlus(m.louka.total)} annonces`,
568 575 `${m.louka.sources} connecteurs actifs`,
576 + `Loyer moyen ${fmtInt(m.louka.avgPrice)} $`,
569 577 "MàJ horaire",
570 578 ],
571 579 "www.immo-ka.com": [
572 580 `${fmtPlus(m.immoka.total)} propriétés`,
573 581 `${m.immoka.sources} connecteurs`,
574 582 `${fmtInt(m.immoka.cities)} villes`,
583 + `Prix moyen ${fmtInt(m.immoka.avgPrice)} $`,
575 584 ],
576 585 "www.auto-ka.com": [
577 586 `${fmtPlus(m.autoka.total)} véhicules`,
578 587 `${m.autoka.sources} concessionnaires`,
579 588 `${m.autoka.regions} régions`,
589 + `Prix moyen ${fmtInt(m.autoka.avgPrice)} $ · ${fmtInt(m.autoka.avgKm)} km`,
580 590 ],
581 591 "www.fabri-ka.com": [
582 592 `${fmtPlus(m.fabrika.products)} produits`,
@@ -602,20 +612,75 @@ function liveChips(m: LiveMetrics): Record<string, string[]> {
602 612 `${fmtPlus(m.creaka.creators)} créateurs`,
603 613 `${fmtPlus(m.creaka.accounts)} comptes reliés`,
604 614 `${m.creaka.platforms} plateformes`,
615 + `${m.creaka.niches} niches`,
605 616 ],
606 617 "www.job-ka.com": [
607 618 `${fmtPlus(m.jobka.total)} offres`,
608 619 `${m.jobka.employers} employeurs`,
609 − `${fmtPlus(m.jobka.withSalary)} salaires publiés`,
620 + `Salaire moyen ${fmtInt(m.jobka.avgSalaryYear)} $/an`,
621 + `${fmtInt(m.jobka.remote)} en télétravail`,
610 622 ],
611 623 "www.vrai-prix.com": [
612 624 `${fmtInt(m.vraiprix.units)} propriétés estimées`,
613 625 `${fmtPlus(m.vraiprix.transactions)} transactions réelles`,
626 + `+${fmtDec(m.vraiprix.growthPct)} % de valeur 2021→2026`,
614 627 "Fourchette P10–P90",
615 628 ],
616 629 };
617 630 }
618 631
632 +/** Santé des dernières synchros, plateforme par plateforme (recent_syncs).
633 + * Une plateforme qui n'expose pas (ou plus) son journal disparaît de la
634 + * liste — jamais de ligne inventée. */
635 +function SyncHealthCard({ m }: { m: LiveMetrics }) {
636 + const rows = [
637 + { name: "Lou·Ka", syncs: m.louka.syncs },
638 + { name: "Immo·Ka", syncs: m.immoka.syncs },
639 + { name: "Auto·Ka", syncs: m.autoka.syncs },
640 + { name: "Fabri·Ka", syncs: m.fabrika.syncs },
641 + { name: "Food·Ka", syncs: m.foodka.syncs },
642 + { name: "Resto·Ka", syncs: m.restoka.syncs },
643 + { name: "Job·Ka", syncs: m.jobka.syncs },
644 + ].filter((r): r is { name: string; syncs: NonNullable<LiveMetrics["louka"]["syncs"]> } =>
645 + r.syncs !== null,
646 + );
647 + if (rows.length === 0) return null;
648 + return (
649 + <div className="gk-card p-5 sm:p-7">
650 + <p className="klabel">Santé des syncs · dernières passes des connecteurs</p>
651 + <h3 className="gk-display mt-2 text-[17px] font-bold uppercase">
652 + La machine, prise sur le fait
653 + </h3>
654 + <div className="mt-5 space-y-3">
655 + {rows.map(({ name, syncs: s }) => (
656 + <div
657 + key={name}
658 + className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-dashed border-[rgba(20,24,20,0.18)] pb-2"
659 + >
660 + <span className="gk-display text-[13.5px] font-bold whitespace-nowrap">
661 + {name}
662 + </span>
663 + <span className="gk-mono text-[11.5px] whitespace-nowrap">
664 + <span className={s.ok === s.runs ? "font-bold text-green" : "font-bold"}>
665 + {s.ok}/{s.runs} OK
666 + </span>
667 + <span className="text-ink-3">
668 + {" "}
669 + · +{fmtInt(s.added)} · ~{fmtInt(s.updated)} · −{fmtInt(s.removed)}
670 + </span>
671 + </span>
672 + </div>
673 + ))}
674 + </div>
675 + <p className="gk-mono mt-4 text-[10.5px] leading-relaxed text-ink-3">
676 + Journal réel des dernières synchronisations publié par chaque
677 + plateforme : passes réussies, annonces ajoutées (+), mises à jour (~)
678 + et retirées (−). Lu en direct, cache 10 min.
679 + </p>
680 + </div>
681 + );
682 +}
683 +
619 684 export default async function Home() {
620 685 const m = await getLiveMetrics();
621 686 const chips = liveChips(m);
@@ -726,12 +791,15 @@ export default async function Home() {
726 791 <span className="stat-chip !text-[11px]">
727 792 {fmtPlus(m.trouveka.domains)} domaines d&apos;ici
728 793 </span>
794 + <span className="stat-chip !text-[11px]">
795 + {fmtInt(m.trouveka.indexedLastHour)} pages indexées / heure
796 + </span>
729 797 <span className="stat-chip !text-[11px]">100 % Québec</span>
730 798 </div>
731 799 <ul className="mt-5 space-y-2 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-5">
732 800 {[
733 801 "Résultats 100 % québécois — l'index ne contient que le web d'ici",
734 − "État du moteur public : pages, domaines et file de crawl à découvert",
802 + `État du moteur public : ${fmtInt(m.trouveka.frontierPending)} pages en file de crawl, ${fmtDec(m.trouveka.embeddingCoverage * 100)} % de l'index couvert par la recherche sémantique`,
735 803 "Soumettez votre site — l'indexation sur demande, ouverte à tous",
736 804 "Robot dédié, documenté, qui respecte robots.txt",
737 805 ].map((f) => (
@@ -863,7 +931,7 @@ export default async function Home() {
863 931 { name: "Immo·Ka", count: m.immoka.sources, kind: "agences" },
864 932 { name: "Resto·Ka", count: m.restoka.chains, kind: "chaînes de restos" },
865 933 { name: "Job·Ka", count: m.jobka.employers, kind: "employeurs (pages carrières)" },
866 − { name: "Sorti·Ka", count: 4, kind: "billetteries & données ouvertes" },
934 + { name: "Sorti·Ka", count: SORTIKA_CONNECTORS, kind: "billetteries & données ouvertes" },
867 935 ]}
868 936 total={m.totals.connectors}
869 937 />
@@ -886,6 +954,49 @@ export default async function Home() {
886 954 </div>
887 955 <CoverageTiles vraiPrixUnits={m.vraiprix.units} />
888 956 </div>
957 + <div className="mt-6 grid items-stretch gap-6 lg:grid-cols-2">
958 + <SyncHealthCard m={m} />
959 + {/* Croisement inter-plateformes calculé par Immo·Ka : prix demandé
960 + vs évaluation municipale (Vrai-Prix), toutes bannières. */}
961 + <div className="gk-card !bg-ink p-6 text-paper sm:p-7">
962 + <p className="kicker !text-lime">
963 + Immo·Ka × Vrai-Prix · le croisement
964 + </p>
965 + <p className="gk-display mt-4 text-[clamp(26px,3.5vw,38px)] leading-[1.05] font-bold tracking-[-0.03em] uppercase">
966 + Prix demandé :
967 + <br />
968 + <span className="text-lime">
969 + +{fmtDec(m.immoka.vraiprix.medianDeltaPct)} %
970 + </span>{" "}
971 + au-dessus
972 + <br />
973 + de l&apos;évaluation
974 + </p>
975 + <p className="mt-4 max-w-md text-[13.5px] text-[rgba(245,243,238,0.75)]">
976 + Sur {fmtInt(m.immoka.vraiprix.n)} annonces croisées avec
977 + l&apos;évaluation municipale (Vrai-Prix), l&apos;écart médian
978 + entre le prix demandé et l&apos;évaluation est de +
979 + {fmtDec(m.immoka.vraiprix.medianDeltaPct)} %. C&apos;est ce que
980 + deux agrégateurs qui se parlent peuvent mesurer — personne
981 + d&apos;autre ne le publie.
982 + </p>
983 + <ul className="mt-5 space-y-2 border-t border-[rgba(245,243,238,0.15)] pt-5 text-[13px] text-[rgba(245,243,238,0.75)]">
984 + {[
985 + `${fmtDec(m.immoka.vraiprix.pctSur10)} % des annonces à plus de +10 % de l'évaluation`,
986 + `${fmtDec(m.immoka.vraiprix.pctJuste)} % proches de l'évaluation`,
987 + `${fmtDec(m.immoka.vraiprix.pctSous5)} % nettement sous l'évaluation`,
988 + ].map((f) => (
989 + <li key={f} className="flex gap-3">
990 + <span
991 + aria-hidden="true"
992 + className="mt-[6px] h-[5px] w-[5px] flex-none rotate-45 bg-lime"
993 + />
994 + {f}
995 + </li>
996 + ))}
997 + </ul>
998 + </div>
999 + </div>
889 1000 </Section>
890 1001
891 1002 {/* ---------- Produits ---------- */}
modified src/app/stats/page.tsx +91 −0
@@ -8,10 +8,14 @@ import type { Metadata } from "next";
8 8 import { fmtInt, getLiveMetrics } from "@/lib/live";
9 9 import {
10 10 PERIODS,
11 + type Breakdown,
11 12 type Kpi,
12 13 type PeriodKey,
13 14 type PlatformStats,
15 + breakdownItems,
14 16 consolidate,
17 + firstBreakdown,
18 + geoBreakdown,
15 19 getEcosystemStats,
16 20 isPeriod,
17 21 mainKpi,
@@ -153,6 +157,67 @@ function PlatformCard({ p }: { p: PlatformStats }) {
153 157 );
154 158 }
155 159
160 +/** Format compact d'un nombre de répartition (réutilise la logique KPI). */
161 +const fmtNum = (v: number): string => fmtVal({ label: "", value: v });
162 +
163 +/** Liste à barres d'une répartition SPEC (breakdown ou geo). */
164 +function BreakdownBars({ b, n = 6 }: { b: Breakdown; n?: number }) {
165 + const items = breakdownItems(b, n);
166 + if (items.length === 0) return null;
167 + const max = Math.max(...items.map((it) => it.value));
168 + return (
169 + <div>
170 + <p className="klabel">{b.title ?? b.label ?? b.id ?? "Répartition"}</p>
171 + <div className="mt-3 space-y-3">
172 + {items.map((it) => (
173 + <div key={it.label}>
174 + <div className="flex items-baseline justify-between gap-3">
175 + <span className="min-w-0 truncate text-[12px] text-ink-2">
176 + {it.label}
177 + </span>
178 + <span className="gk-mono flex-none text-[11.5px] font-bold">
179 + {fmtNum(it.value)}
180 + </span>
181 + </div>
182 + <div className="mt-[4px] h-[9px] w-full rounded-r-[3px] bg-[rgba(20,24,20,0.06)]">
183 + <div
184 + className="h-full rounded-r-[3px] bg-green"
185 + style={{ width: `${Math.max((it.value / max) * 100, 1.5)}%` }}
186 + />
187 + </div>
188 + </div>
189 + ))}
190 + </div>
191 + </div>
192 + );
193 +}
194 +
195 +/** Carte « répartitions » d'une plateforme phare : 1er breakdown + geo. */
196 +function BreakdownCard({ p }: { p: PlatformStats }) {
197 + const bd = firstBreakdown(p.dash);
198 + const geo = geoBreakdown(p.dash);
199 + if (!bd && !geo) return null;
200 + return (
201 + <article className="gk-card p-5 sm:p-6">
202 + <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
203 + <span className="flex min-w-0 items-center gap-2">
204 + <Pastille accent={p.site.accent} />
205 + <span className="gk-display truncate text-[17px] font-bold tracking-[-0.02em]">
206 + {p.site.wordmark}
207 + </span>
208 + </span>
209 + <span className="gk-mono text-[10.5px] font-bold text-ink-3">
210 + {p.site.domain}
211 + </span>
212 + </div>
213 + <div className="mt-5 grid gap-6 sm:grid-cols-2">
214 + {bd ? <BreakdownBars b={bd} /> : null}
215 + {geo ? <BreakdownBars b={geo} n={5} /> : null}
216 + </div>
217 + </article>
218 + );
219 +}
220 +
156 221 export default async function StatsPage({
157 222 searchParams,
158 223 }: {
@@ -178,6 +243,12 @@ export default async function StatsPage({
178 243 const reportHref = (mode: "complet" | "synthese") =>
179 244 `/api/stats/ecosystem-report?period=${period}&mode=${mode}`;
180 245
246 + // Plateformes phares dont le tableau de bord publie des répartitions
247 + // (breakdowns / geo du contrat SPEC) — les 2 plus grosses jointes.
248 + const richPlatforms = c.ranked
249 + .filter((p) => firstBreakdown(p.dash) || geoBreakdown(p.dash))
250 + .slice(0, 2);
251 +
181 252 const banner: { label: string; value: string; note?: string }[] = [
182 253 {
183 254 label: "Volume principal agrégé",
@@ -353,6 +424,26 @@ export default async function StatsPage({
353 424 </p>
354 425 </section>
355 426
427 + {/* 3b — répartitions des plateformes phares (breakdowns/geo du SPEC) */}
428 + {richPlatforms.length > 0 ? (
429 + <section className="mt-14">
430 + <p className="kicker">Sous le capot · répartitions</p>
431 + <h2 className="gk-display mt-3 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]">
432 + Les plus grosses, décomposées
433 + </h2>
434 + <p className="mt-3 max-w-2xl text-[13px] text-ink-2">
435 + Les tableaux de bord des plateformes publient aussi leurs
436 + répartitions — par type, par ville, par région. Voici celles des
437 + plus gros inventaires joints, lues telles quelles.
438 + </p>
439 + <div className="mt-5 grid gap-5 lg:grid-cols-2">
440 + {richPlatforms.map((p) => (
441 + <BreakdownCard key={p.site.id} p={p} />
442 + ))}
443 + </div>
444 + </section>
445 + ) : null}
446 +
356 447 {/* 4 — un bloc résumé par plateforme */}
357 448 <section className="mt-14">
358 449 <p className="kicker">Plateforme par plateforme</p>
modified src/lib/ecostats.ts +39 −0
@@ -18,11 +18,23 @@ export type Kpi = {
18 18
19 19 export type KaRecord = { label: string; value: string; date?: string };
20 20
21 +export type BreakdownItem = { label: string; value: number };
22 +
23 +export type Breakdown = {
24 + id?: string;
25 + title?: string;
26 + label?: string;
27 + kind?: string;
28 + items?: BreakdownItem[];
29 +};
30 +
21 31 export type Dashboard = {
22 32 updated?: string;
23 33 period?: { from?: string; to?: string; label?: string };
24 34 kpis?: Kpi[];
25 35 records?: KaRecord[];
36 + breakdowns?: Breakdown[];
37 + geo?: Breakdown | null;
26 38 };
27 39
28 40 export type Site = {
@@ -89,6 +101,33 @@ export function topKpis(dash: Dashboard | null, n = 3): Kpi[] {
89 101 return (dash?.kpis ?? []).filter((k) => isNum(k.value)).slice(0, n);
90 102 }
91 103
104 +/** Items valides d'un bloc de répartition (breakdowns[] / geo du SPEC). */
105 +export function breakdownItems(b: Breakdown | null | undefined, n = 6): BreakdownItem[] {
106 + if (!b || !Array.isArray(b.items)) return [];
107 + return b.items
108 + .filter(
109 + (it): it is BreakdownItem =>
110 + !!it &&
111 + typeof it === "object" &&
112 + typeof it.label === "string" &&
113 + isNum(it.value),
114 + )
115 + .slice(0, n);
116 +}
117 +
118 +/** Première répartition exploitable d'un tableau de bord SPEC. */
119 +export function firstBreakdown(dash: Dashboard | null): Breakdown | null {
120 + for (const b of dash?.breakdowns ?? [])
121 + if (breakdownItems(b, 1).length > 0) return b;
122 + return null;
123 +}
124 +
125 +/** Bloc géographique du SPEC (top villes / régions), s'il est publié. */
126 +export function geoBreakdown(dash: Dashboard | null): Breakdown | null {
127 + const g = dash?.geo;
128 + return g && breakdownItems(g, 1).length > 0 ? g : null;
129 +}
130 +
92 131 export const dataSites: Site[] = (eco.sites as Site[]).filter(
93 132 (s) => s.id !== "groupe-ka",
94 133 );
modified src/lib/live.ts +227 −19
@@ -1,8 +1,11 @@
1 1 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 2 // Métriques LIVE de l'écosystème — mesurées sur les plateformes elles-mêmes
3 3 // (endpoint public /api/stats de chaque site, cache serveur 10 min). Chaque
4 −// valeur a un repli figé (dernier état connu du 2026-08-17) : si une
4 +// valeur a un repli figé (dernier état connu du 2026-08-18) : si une
5 5 // plateforme ne répond pas, le site mère affiche le repli plutôt que zéro.
6 +// Les champs « riches » (moyennes, géo, santé des syncs, croisement
7 +// Immo·Ka × Vrai-Prix…) suivent le même contrat : repli champ par champ,
8 +// jamais de rendu cassé.
6 9
7 10 type Json = Record<string, unknown>;
8 11
@@ -29,21 +32,174 @@ const num = (o: Json | null, path: string, fallback: number): number => {
29 32 return typeof cur === "number" && Number.isFinite(cur) ? cur : fallback;
30 33 };
31 34
35 +const str = (o: Json | null, path: string, fallback: string | null): string | null => {
36 + let cur: unknown = o;
37 + for (const k of path.split(".")) {
38 + if (cur == null || typeof cur !== "object") return fallback;
39 + cur = (cur as Json)[k];
40 + }
41 + return typeof cur === "string" && cur.length > 0 ? cur : fallback;
42 +};
43 +
44 +/** Nombre de clés d'un objet de répartition (by_platform, by_niche…). */
45 +const keyCount = (o: Json | null, key: string, fallback: number): number =>
46 + o && typeof o[key] === "object" && o[key] !== null && !Array.isArray(o[key])
47 + ? Object.keys(o[key] as Json).length
48 + : fallback;
49 +
50 +/** Santé des dernières synchros d'un connecteur (recent_syncs / sync_log). */
51 +export type SyncHealth = {
52 + runs: number;
53 + ok: number;
54 + added: number;
55 + updated: number;
56 + removed: number;
57 +} | null;
58 +
59 +function syncSummary(o: Json | null, key = "recent_syncs"): SyncHealth {
60 + const arr = o?.[key];
61 + if (!Array.isArray(arr) || arr.length === 0) return null;
62 + const s = { runs: 0, ok: 0, added: 0, updated: 0, removed: 0 };
63 + for (const it of arr) {
64 + if (!it || typeof it !== "object") continue;
65 + const r = it as Json;
66 + s.runs += 1;
67 + if (r.ok === 1 || r.ok === true || r.status === "ok") s.ok += 1;
68 + for (const k of ["added", "updated", "removed"] as const) {
69 + const v = r[k];
70 + if (typeof v === "number" && Number.isFinite(v)) s[k] += v;
71 + }
72 + }
73 + return s.runs > 0 ? s : null;
74 +}
75 +
76 +// Sorti·Ka n'expose pas (encore) de comptage de connecteurs sur /api/stats
77 +// (vérifié 2026-08-18 : total_active, upcoming, free_upcoming, cities,
78 +// by_region, by_category seulement).
79 +// TODO : lire le champ dès qu'il sera publié côté sorti-ka.
80 +export const SORTIKA_CONNECTORS = 8; // billetteries & données ouvertes actives
81 +
32 82 export type LiveMetrics = {
33 − louka: { total: number; sources: number };
34 − immoka: { total: number; sources: number; cities: number };
35 − autoka: { total: number; sources: number; regions: number };
36 − fabrika: { products: number; stores: number; regions: number };
37 − foodka: { total: number; sources: number; onSale: number };
38 − restoka: { restaurants: number; items: number; chains: number; regions: number };
39 − sortika: { active: number; free: number; cities: number };
40 − creaka: { creators: number; accounts: number; platforms: number };
41 − trouveka: { pages: number; domains: number; running: boolean };
42 − vraiprix: { units: number; transactions: number; valueTotal: number };
43 − jobka: { total: number; employers: number; withSalary: number };
83 + louka: {
84 + total: number;
85 + sources: number;
86 + avgPrice: number;
87 + geo: { quebec: number; levis: number; montreal: number };
88 + syncs: SyncHealth;
89 + };
90 + immoka: {
91 + total: number;
92 + sources: number;
93 + cities: number;
94 + avgPrice: number;
95 + // Croisement inter-plateformes calculé par Immo·Ka : écart entre le prix
96 + // demandé et l'évaluation municipale (Vrai-Prix), toutes bannières.
97 + vraiprix: {
98 + n: number;
99 + medianDeltaPct: number;
100 + pctSur10: number;
101 + pctJuste: number;
102 + pctSous5: number;
103 + };
104 + syncs: SyncHealth;
105 + };
106 + autoka: {
107 + total: number;
108 + sources: number;
109 + regions: number;
110 + avgPrice: number;
111 + avgKm: number;
112 + avgYear: number;
113 + syncs: SyncHealth;
114 + };
115 + fabrika: {
116 + products: number;
117 + stores: number;
118 + storesRegistry: number;
119 + regions: number;
120 + syncs: SyncHealth;
121 + };
122 + foodka: {
123 + total: number;
124 + sources: number;
125 + onSale: number;
126 + avgPrice: number;
127 + syncs: SyncHealth;
128 + };
129 + restoka: {
130 + restaurants: number;
131 + items: number;
132 + chains: number;
133 + regions: number;
134 + syncs: SyncHealth;
135 + };
136 + sortika: {
137 + active: number;
138 + free: number;
139 + upcoming: number;
140 + cities: number;
141 + regions: number;
142 + };
143 + creaka: {
144 + creators: number;
145 + accounts: number;
146 + platforms: number;
147 + niches: number;
148 + tiers: { nano: number; micro: number; macro: number; mega: number };
149 + };
150 + trouveka: {
151 + pages: number;
152 + domains: number;
153 + running: boolean;
154 + indexedLastHour: number;
155 + errorsLastHour: number;
156 + frontierPending: number;
157 + embeddingCoverage: number;
158 + };
159 + vraiprix: {
160 + units: number;
161 + transactions: number;
162 + valueTotal: number;
163 + valueMedian: number;
164 + growthPct: number;
165 + municipalities: number;
166 + modelVersion: string | null;
167 + updated: string | null;
168 + };
169 + jobka: {
170 + total: number;
171 + employers: number;
172 + withSalary: number;
173 + avgSalaryYear: number;
174 + remote: number;
175 + topCities: { city: string; n: number }[];
176 + syncs: SyncHealth;
177 + };
44 178 totals: { connectors: number; items: number };
45 179 };
46 180
181 +const topCities = (o: Json | null): { city: string; n: number }[] => {
182 + const arr = o?.top_cities;
183 + if (Array.isArray(arr)) {
184 + const out = arr
185 + .filter(
186 + (c): c is { city: string; n: number } =>
187 + !!c &&
188 + typeof c === "object" &&
189 + typeof (c as Json).city === "string" &&
190 + typeof (c as Json).n === "number",
191 + )
192 + .slice(0, 3);
193 + if (out.length > 0) return out;
194 + }
195 + // repli figé (2026-08-18)
196 + return [
197 + { city: "Montréal", n: 1671 },
198 + { city: "Québec", n: 814 },
199 + { city: "Laval", n: 145 },
200 + ];
201 +};
202 +
47 203 export async function getLiveMetrics(): Promise<LiveMetrics> {
48 204 const [lou, immo, auto, fab, food, resto, sorti, crea, trouve, vp, job] =
49 205 await Promise.all([
@@ -61,59 +217,107 @@ export async function getLiveMetrics(): Promise<LiveMetrics> {
61 217 ]);
62 218
63 219 const m: LiveMetrics = {
64 − louka: { total: num(lou, "total", 33115), sources: num(lou, "sources", 207) },
220 + louka: {
221 + total: num(lou, "total", 33115),
222 + sources: num(lou, "sources", 207),
223 + avgPrice: num(lou, "avg_price", 1786),
224 + geo: {
225 + quebec: num(lou, "quebec", 3117),
226 + levis: num(lou, "levis", 1557),
227 + montreal: num(lou, "montreal", 16466),
228 + },
229 + syncs: syncSummary(lou),
230 + },
65 231 immoka: {
66 232 total: num(immo, "total", 67079),
67 233 sources: num(immo, "sources", 38),
68 234 cities: num(immo, "cities", 3114),
235 + avgPrice: num(immo, "avg_price", 686690),
236 + vraiprix: {
237 + n: num(immo, "vraiprix.ensemble.n", 44396),
238 + medianDeltaPct: num(immo, "vraiprix.ensemble.median_delta_pct", 9.0),
239 + pctSur10: num(immo, "vraiprix.ensemble.pct_sur10", 48.6),
240 + pctJuste: num(immo, "vraiprix.ensemble.pct_juste", 24.2),
241 + pctSous5: num(immo, "vraiprix.ensemble.pct_sous5", 27.2),
242 + },
243 + syncs: syncSummary(immo),
69 244 },
70 245 autoka: {
71 246 total: num(auto, "total", 17098),
72 247 sources: num(auto, "sources", 125),
73 248 regions: num(auto, "regions", 18),
249 + avgPrice: num(auto, "avg_price", 28576),
250 + avgKm: num(auto, "avg_km", 82680),
251 + avgYear: num(auto, "avg_year", 2021),
252 + syncs: syncSummary(auto),
74 253 },
75 254 fabrika: {
76 255 products: num(fab, "totals.products", 291557),
77 256 stores: num(fab, "totals.stores_live", 1265),
257 + storesRegistry: num(fab, "totals.stores_registry", 3224),
78 258 regions: num(fab, "totals.regions", 17),
259 + syncs: syncSummary(fab, "sync_log"),
79 260 },
80 261 foodka: {
81 262 total: num(food, "total", 28050),
82 263 sources: num(food, "sources", 43),
83 264 onSale: num(food, "on_sale", 4922),
265 + avgPrice: num(food, "avg_price", 10.48),
266 + syncs: syncSummary(food),
84 267 },
85 268 restoka: {
86 269 restaurants: num(resto, "restaurants", 14091),
87 270 items: num(resto, "items", 79159),
88 271 chains: num(resto, "chains", 24),
89 272 regions: num(resto, "regions", 17),
273 + syncs: syncSummary(resto),
90 274 },
91 275 sortika: {
92 276 active: num(sorti, "total_active", 15229),
93 277 free: num(sorti, "free_upcoming", 3159),
278 + upcoming: num(sorti, "upcoming", 11666),
94 279 cities: num(sorti, "cities", 428),
280 + regions: keyCount(sorti, "by_region", 17),
95 281 },
96 282 creaka: {
97 283 creators: num(crea, "creators", 5103),
98 284 accounts: num(crea, "accounts", 5664),
99 − platforms: crea && typeof crea.by_platform === "object" && crea.by_platform
100 − ? Object.keys(crea.by_platform as Json).length
101 − : 10,
285 + platforms: keyCount(crea, "by_platform", 10),
286 + niches: keyCount(crea, "by_niche", 20),
287 + tiers: {
288 + nano: num(crea, "by_tier.nano", 4671),
289 + micro: num(crea, "by_tier.micro", 284),
290 + macro: num(crea, "by_tier.macro", 112),
291 + mega: num(crea, "by_tier.mega", 36),
292 + },
102 293 },
103 294 trouveka: {
104 295 pages: num(trouve, "pages_indexed", 1170502),
105 296 domains: num(trouve, "domains_count", 72280),
106 297 running: (trouve?.crawler_state ?? "running") === "running",
298 + indexedLastHour: num(trouve, "indexed_last_hour", 22505),
299 + errorsLastHour: num(trouve, "errors_last_hour", 257),
300 + frontierPending: num(trouve, "frontier_pending", 2500000),
301 + embeddingCoverage: num(trouve, "embedding_coverage", 1.0),
107 302 },
108 303 vraiprix: {
109 304 units: num(vp, "units_total", 3747008),
110 305 transactions: num(vp, "transactions", 745119),
111 306 valueTotal: num(vp, "value_total_2026", 2009452277400),
307 + valueMedian: num(vp, "value_median_2026", 444300),
308 + growthPct: num(vp, "growth_2021_2026_pct", 43.1),
309 + municipalities: num(vp, "municipalities", 1097),
310 + modelVersion: str(vp, "model_version", null),
311 + updated: str(vp, "updated", null),
112 312 },
113 313 jobka: {
114 314 total: num(job, "total", 4449),
115 315 employers: num(job, "employers", 135),
116 316 withSalary: num(job, "with_salary", 1069),
317 + avgSalaryYear: num(job, "avg_salary_year", 72499),
318 + remote: num(job, "remote", 351),
319 + topCities: topCities(job),
320 + syncs: syncSummary(job),
117 321 },
118 322 totals: { connectors: 0, items: 0 },
119 323 };
@@ -125,7 +329,7 @@ export async function getLiveMetrics(): Promise<LiveMetrics> {
125 329 m.foodka.sources +
126 330 m.restoka.chains +
127 331 m.jobka.employers +
128 − 4; // Sorti·Ka : billetteries & données ouvertes (pas d'API de comptage)
332 + SORTIKA_CONNECTORS;
129 333 m.totals.items =
130 334 m.louka.total +
131 335 m.immoka.total +
@@ -141,9 +345,13 @@ export async function getLiveMetrics(): Promise<LiveMetrics> {
141 345 }
142 346
143 347 /* ---- Formatage fr-CA ---- */
144 −const NBSP = " ";
348 +const NBSP = " ";
145 349 export const fmtInt = (n: number): string =>
146 − n.toLocaleString("fr-CA").replace(/\s/g, NBSP);
350 + Math.round(n).toLocaleString("fr-CA").replace(/\s/g, NBSP);
351 +
352 +/** Nombre décimal fr-CA (1 décimale max) — pour les pourcentages. */
353 +export const fmtDec = (n: number): string =>
354 + n.toLocaleString("fr-CA", { maximumFractionDigits: 1 }).replace(/\s/g, NBSP);
147 355
148 356 /** Arrondi « éditorial » vers le bas + suffixe « + » : 33 115 → « 33 100+ ». */
149 357 export const fmtPlus = (n: number): string => {
150 358