SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

feat(fiche): dossier d'intelligence locative — passe sharp desktop/mobile + PDF complet

CSS : pastilles de statut affûtées, score hiver et note Google en tuiles
à ombre dure, timeline encre, media queries 640px (tableau coût réel,
dates empilées, barres d'avis compactes). PDF fiche : cinq nouvelles
sections fidèles au web — coût réel ligne par ligne (statuts étiquetés),
historique Lou-Ka + vies antérieures, passeport de l'immeuble (tuiles
KPI, rotation, pression), gestionnaire (note Google, distribution des
avis, plaintes, confiance d'association), score hiver.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent a58cabd

2 changed files +375 −9

modified frontend/src/styles.css +38 −7
@@ -2124,9 +2124,10 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2124 2124 display: inline-block;
2125 2125 font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
2126 2126 text-transform: uppercase; letter-spacing: 0.06em;
2127 − padding: 2px 7px; border: 1px solid currentColor; border-radius: var(--r-pill, 999px);
2128 − white-space: nowrap;
2127 + padding: 2px 7px; border: 1.5px solid currentColor; border-radius: 3px;
2128 + white-space: nowrap; vertical-align: 1px;
2129 2129 }
2130 +.cr-poste .st-pill { margin-left: 8px; }
2130 2131 .st-included { color: #2e7d4a; background: #eef7f0; }
2131 2132 .st-observed { color: var(--ink); background: var(--surface); }
2132 2133 .st-estimated { color: var(--accent-deep, #cc5500); background: var(--accent-soft, #fff1e6); }
@@ -2136,6 +2137,8 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2136 2137 .cr-table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
2137 2138 .cr-table td { padding: 7px 8px 7px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
2138 2139 .cr-table tbody tr:nth-child(even) td { background: var(--surface-2, #faf9f5); }
2140 +.cr-table tbody tr:hover td { background: var(--accent-soft, #fff1e6); }
2141 +.cr-table tfoot tr:first-child td { background: var(--accent-soft, #fff1e6); }
2139 2142 .cr-poste { color: var(--ink-2); }
2140 2143 .cr-montant {
2141 2144 text-align: right; font-family: var(--font-mono); font-weight: 600;
@@ -2158,7 +2161,7 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2158 2161 /* --- Historique Lou-Ka : timeline --- */
2159 2162 .hl-timeline {
2160 2163 list-style: none; margin: 12px 0 0; padding: 0 0 0 14px;
2161 − border-left: 2px solid var(--line);
2164 + border-left: 2px solid var(--ink);
2162 2165 display: flex; flex-direction: column; gap: 8px;
2163 2166 }
2164 2167 .hl-item { position: relative; font-size: 13px; color: var(--ink-2); }
@@ -2208,10 +2211,14 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2208 2211 .gest-head { margin-bottom: 10px; }
2209 2212 .gest-nom { font-family: var(--font-display); font-weight: 700; font-size: 17px; }
2210 2213 .gest-meta { font-size: 12.5px; color: var(--ink-2); margin-top: 2px; }
2211 −.gest-google { display: flex; align-items: baseline; gap: 10px; margin: 8px 0; }
2214 +.gest-google { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
2212 2215 .gest-note {
2213 − font-family: var(--font-display); font-weight: 700; font-size: 22px;
2216 + font-family: var(--font-display); font-weight: 700; font-size: 20px;
2214 2217 color: var(--accent-deep, #cc5500);
2218 + background: var(--surface); border: 1.5px solid var(--ink);
2219 + border-radius: var(--r-ctl); padding: 5px 10px;
2220 + box-shadow: 3px 3px 0 var(--accent-soft, #fff1e6);
2221 + white-space: nowrap;
2215 2222 }
2216 2223 .gest-navis { font-size: 12.5px; color: var(--ink-2); }
2217 2224 .gest-bars { margin: 10px 0; display: flex; flex-direction: column; gap: 4px; }
@@ -2254,10 +2261,13 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2254 2261 }
2255 2262
2256 2263 /* --- Vie quotidienne en hiver --- */
2257 −.hiver-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
2264 +.hiver-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
2258 2265 .hiver-score {
2259 − font-family: var(--font-display); font-weight: 700; font-size: 34px;
2266 + font-family: var(--font-display); font-weight: 700; font-size: 30px;
2260 2267 line-height: 1; color: var(--ink);
2268 + background: var(--surface); border: 1.5px solid var(--ink);
2269 + border-radius: var(--r-ctl); padding: 8px 12px;
2270 + box-shadow: 3px 3px 0 var(--accent-soft, #fff1e6);
2261 2271 }
2262 2272 .hiver-sur { font-size: 12.5px; color: var(--ink-3); }
2263 2273 .hiver-detail {
@@ -2279,3 +2289,24 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
2279 2289 font-family: var(--font-mono); font-weight: 700; font-size: 12px;
2280 2290 min-width: 28px; text-align: right;
2281 2291 }
2292 +
2293 +/* --- Adaptatif mobile : dossier d'intelligence locative --- */
2294 +@media (max-width: 640px) {
2295 + .cr-table { font-size: 12.5px; }
2296 + .cr-table td { padding: 6px 6px 6px 0; }
2297 + .cr-poste .st-pill { margin-left: 6px; }
2298 + .cr-montant { font-size: 12.5px; }
2299 + .hl-date { display: block; min-width: 0; margin-bottom: 2px; }
2300 + .hl-item { font-size: 12.5px; }
2301 + .hl-match { font-size: 12.5px; }
2302 + .im-indic .im-detail { display: block; margin-top: 3px; }
2303 + .gest-nom { font-size: 16px; }
2304 + .gest-stats { font-size: 12.5px; gap: 8px; }
2305 + .gest-citation { font-size: 12.5px; }
2306 + .gb-n { min-width: 24px; font-size: 10.5px; }
2307 + .gb-c { min-width: 20px; font-size: 10.5px; }
2308 + .hiver-score { font-size: 26px; padding: 6px 10px; }
2309 + .hiver-sur { flex-basis: 100%; }
2310 + .hd-crit { min-width: 86px; font-size: 10px; }
2311 + .hiver-detail li { font-size: 12.5px; }
2312 +}
modified louka/pdfgen.py +337 −2
@@ -4,9 +4,12 @@
4 4 # pdfgen.py : génération PDF à l'identité visuelle officielle Lou-Ka
5 5 # - fiche_pdf(uid) : fiche de propriété complète — tout ce que montre la
6 6 # fiche web : photos, prix + analyse Lou-Ka (juste valeur, histogramme),
7 −# chips, description structurée, inclusions, registre des loyers,
7 +# chips, description structurée, inclusions, coût réel mensuel,
8 +# historique Lou-Ka (+ vies antérieures), passeport de l'immeuble,
9 +# gestionnaire (fiche Google + avis synchronisés), registre des loyers,
8 10 # historique TAL, inondation, qualité de l'air, coût d'électricité,
9 −# commerces/transport, essence, KA Scores, quartier, à proximité, QR
11 +# commerces/transport, essence, KA Scores, score hiver, quartier,
12 +# à proximité, QR
10 13 # - rapport_pdf() : rapport global du marché (totaux, régions, histogramme
11 14 # des loyers, types, villes, gestionnaires)
12 15 # Palette identique au site : blanc #ffffff, marine #0b1330, orange #ff6a00,
@@ -241,6 +244,15 @@ def _fmt_mois(d: str | None) -> str:
241 244 return d
242 245
243 246
247 +def _fmt_ts(ts, jour: bool = False) -> str:
248 + """Timestamp unix → « 6 août 2026 » (jour=True) ou « août 2026 »."""
249 + if not ts:
250 + return "—"
251 + t = time.localtime(ts)
252 + mois = f"{_MOIS[t.tm_mon - 1]} {t.tm_year}"
253 + return f"{t.tm_mday} {mois}" if jour else mois
254 +
255 +
244 256 _VERDICTS = {
245 257 "sous": ("Sous le marché", GREEN_DEEP, LIME_SOFT, GREEN),
246 258 "marche": ("Dans le marché", INK2, SURFACE, INK),
@@ -320,6 +332,20 @@ def fiche_pdf(uid: str) -> bytes | None:
320 332 com = _safe(com_mod.nearby, lat, lng) if geo else None
321 333 hyd = _safe(hydro_mod.estimate, adresse=l.get("address") or "",
322 334 uid=uid, lat=lat, lng=lng, solve=False)
335 + # dossier d'intelligence locative (mêmes modules que les blocs du site)
336 + from . import building as bld_mod
337 + from . import coutreel as cr_mod
338 + from . import history as hist_mod
339 + from . import hiver as hiv_mod
340 + from . import managers as mgr_mod
341 + from . import recycled as rec_mod
342 + cout = _safe(cr_mod.compute, uid)
343 + histo = _safe(hist_mod.timeline, uid)
344 + recyc = _safe(rec_mod.lookup, uid)
345 + imm = (_safe(bld_mod.fiche, l["building_key"])
346 + if l.get("building_key") else None)
347 + hiv = _safe(hiv_mod.score, lat, lng) if geo else None
348 + gest = _safe(mgr_mod.fiche, l["source"])
323 349
324 350 buf = io.BytesIO()
325 351 c = rl_canvas.Canvas(buf, pagesize=letter)
@@ -568,6 +594,277 @@ def fiche_pdf(uid: str) -> bytes | None:
568 594 c.drawString(M, y - 2, f"Historique de prix de l'annonce : {chn}")
569 595 y -= 16
570 596
597 + # --- Coût réel mensuel (loyer + frais, chaque poste étiqueté honnêtement)
598 + if cout and cout.get("loyer") is not None:
599 + lignes_cr = cout.get("lignes") or []
600 + y = place(y, 58 + len(lignes_cr) * 12 + 46)
601 + y = s.titre_section(y, "Coût réel mensuel")
602 + stat_cr = {"included": ("INCLUS", GREEN), "observed": ("OBSERVÉ", INK),
603 + "estimated": ("ESTIMÉ", LIME), "unknown": ("INCONNU", INK3)}
604 + for li in lignes_cr:
605 + lab, col = stat_cr.get(li.get("statut"), stat_cr["unknown"])
606 + c.setFont("Helvetica", 8.5)
607 + c.setFillColor(INK2)
608 + c.drawString(M, y, (li.get("poste") or "")[:46])
609 + c.setFont("Courier-Bold", 6.5)
610 + c.setFillColor(col)
611 + c.drawString(M + 230, y, lab)
612 + m_ = li.get("montant")
613 + if m_ is not None and m_ > 0:
614 + mtx = _fmt_money(m_)
615 + elif m_ == 0 and li.get("statut") == "included":
616 + mtx = f"0{NBSP}$"
617 + else:
618 + mtx = "—"
619 + c.setFont("Helvetica-Bold", 8.5)
620 + c.setFillColor(INK)
621 + c.drawRightString(PAGE_W - M, y, mtx)
622 + y -= 12
623 + if cout.get("total_estime") is not None:
624 + c.setStrokeColor(INK)
625 + c.setLineWidth(1.2)
626 + c.line(M, y + 8, PAGE_W - M, y + 8)
627 + c.setFont("Helvetica-Bold", 10)
628 + c.setFillColor(INK)
629 + c.drawString(M, y - 4, "Total estimé")
630 + tot = f"≈{NBSP}{_fmt_money(cout['total_estime'])} / mois"
631 + if cout.get("annuel_estime") is not None:
632 + tot += f" · ≈{NBSP}{_fmt_money(cout['annuel_estime'])} / an"
633 + c.drawRightString(PAGE_W - M, y - 4, tot)
634 + y -= 18
635 + if cout.get("postes_inconnus"):
636 + y = s.paragraphe(M, y - 2, "Postes non chiffrables avec les données "
637 + "publiées : "
638 + + ", ".join(cout["postes_inconnus"]).lower()
639 + + " — le total réel peut être plus élevé.",
640 + PAGE_W - 2 * M, taille=7.5,
641 + couleur=HexColor("#8a5a12"), interligne=9.5,
642 + max_lignes=3)
643 + pi2_d = cout.get("pi2")
644 + if pi2_d and pi2_d.get("valeur") is not None:
645 + v = f"{pi2_d['valeur']:.2f}".replace(".", ",")
646 + suite = f"{v}{NBSP}$/pi²"
647 + if pi2_d.get("percentile_secteur") is not None:
648 + suite += (f" — moins cher que {100 - pi2_d['percentile_secteur']}"
649 + f"{NBSP}% des {pi2_d.get('n_secteur')} comparables du "
650 + "secteur (~2 km)")
651 + elif pi2_d.get("percentile_ville") is not None:
652 + suite += (f" — moins cher que {100 - pi2_d['percentile_ville']}"
653 + f"{NBSP}% des {pi2_d.get('n_ville')} comparables "
654 + f"({pi2_d.get('portee_ville', '')})")
655 + c.setFont("Helvetica", 8)
656 + c.setFillColor(INK2)
657 + c.drawString(M, y - 2, suite[:120])
658 + y -= 12
659 + y -= 8
660 +
661 + # --- Historique Lou-Ka (observations réelles des synchronisations)
662 + if histo:
663 + tl = (histo.get("timeline") or [])[:8]
664 + matches = ((recyc or {}).get("matches") or [])[:2]
665 + y = place(y, 46 + len(tl) * 11 + len(matches) * 22 + 18)
666 + y = s.titre_section(y, "Historique Lou-Ka")
667 + entete_h = (f"Suivie depuis {_fmt_ts(histo.get('premiere_observation'), jour=True)}"
668 + f" · {histo.get('jours_en_ligne', 0)} jour"
669 + f"{'s' if (histo.get('jours_en_ligne') or 0) > 1 else ''} en ligne"
670 + f" · {histo.get('modifications', 0)} modification"
671 + f"{'s' if (histo.get('modifications') or 0) > 1 else ''} observée"
672 + f"{'s' if (histo.get('modifications') or 0) > 1 else ''}")
673 + pi_, pa_ = histo.get("prix_initial"), histo.get("prix_actuel")
674 + if pi_ is not None and pa_ is not None and pi_ != pa_:
675 + var_ = histo.get("variation")
676 + entete_h += f" · {_fmt_money(pi_)} → {_fmt_money(pa_)}"
677 + if var_ is not None:
678 + entete_h += (f" ({'+' if var_ > 0 else '−'}"
679 + f"{abs(round(var_ * 100))}{NBSP}%)")
680 + c.setFont("Helvetica-Bold", 8.5)
681 + c.setFillColor(INK)
682 + c.drawString(M, y - 2, entete_h[:120])
683 + y -= 16
684 + ev_labels = {"description": "Description modifiée",
685 + "superficie": "Superficie modifiée",
686 + "dispo": "Disponibilité modifiée",
687 + "inclusions": "Inclusions modifiées",
688 + "photos": "Photos modifiées",
689 + "disparition": "Annonce retirée",
690 + "reapparition": "Annonce republiée"}
691 + if tl:
692 + for it in tl:
693 + c.setFont("Courier-Bold", 7)
694 + c.setFillColor(INK3)
695 + c.drawString(M, y, _fmt_ts(it.get("ts"), jour=True).upper())
696 + c.setFont("Helvetica", 8)
697 + c.setFillColor(INK2)
698 + if it.get("type") == "prix":
699 + if it.get("prix_avant") is not None:
700 + sens = ("baissé" if it["prix_avant"] > (it.get("prix") or 0)
701 + else "monté")
702 + txt_ev = (f"Prix {sens} de {_fmt_money(it['prix_avant'])} "
703 + f"à {_fmt_money(it.get('prix'))}")
704 + else:
705 + txt_ev = f"Premier prix observé : {_fmt_money(it.get('prix'))}"
706 + else:
707 + txt_ev = ev_labels.get(it.get("type"), it.get("type") or "")
708 + c.drawString(M + 78, y, txt_ev[:95])
709 + y -= 11
710 + else:
711 + c.setFont("Helvetica", 8)
712 + c.setFillColor(INK2)
713 + c.drawString(M, y, "Aucune modification observée depuis la première "
714 + "synchronisation de cette annonce.")
715 + y -= 11
716 + for m_ in matches:
717 + suite = f"Vie antérieure probable ({m_.get('confiance')}{NBSP}% de confiance)"
718 + if m_.get("prix") is not None:
719 + suite += f" — affiché {_fmt_money(m_['prix'])}"
720 + if m_.get("derniere_observation"):
721 + suite += f" jusqu'en {_fmt_ts(m_['derniere_observation'])}"
722 + c.setFont("Helvetica-Bold", 8)
723 + c.setFillColor(HexColor("#8a5a12"))
724 + c.drawString(M, y - 2, suite[:115])
725 + y -= 10
726 + c.setFont("Helvetica", 7)
727 + c.setFillColor(INK3)
728 + c.drawString(M, y - 2, ("signaux : " + " · ".join(m_.get("signaux") or []))[:125])
729 + y -= 12
730 + y -= 8
731 +
732 + # --- Passeport de l'immeuble (ce que Lou-Ka observe du bâtiment)
733 + if imm and (imm.get("annonces_total") or 0) >= 2:
734 + y = place(y, 128)
735 + y = s.titre_section(y, "Passeport de l'immeuble")
736 + tw = (PAGE_W - 2 * M - 30) / 4
737 + rot = imm.get("rotation") or {}
738 + pres = imm.get("pression_loyers")
739 + tuiles_im = [
740 + (str(imm["annonces_total"]),
741 + f"annonces obs. ({imm.get('annonces_actives', 0)} actives)"),
742 + (f"≥{NBSP}{imm.get('unites_estimees', 0)}", "unités estimées"),
743 + (_fmt_money(imm.get("loyer_median")), "loyer médian (actives)"),
744 + (f"{imm['pi2_median']:.2f}".replace(".", ",") + f"{NBSP}$"
745 + if imm.get("pi2_median") is not None else "—", "médiane $/pi²"),
746 + ]
747 + for i, (val, lab) in enumerate(tuiles_im):
748 + s.tuile(M + i * (tw + 10), y - 44, tw, 38, val, lab)
749 + y -= 58
750 + par_cc = imm.get("loyer_median_par_cc") or {}
751 + if par_cc:
752 + parts = [f"{cc}{NBSP}ch. {_fmt_money(v)}"
753 + for cc, v in sorted(par_cc.items())]
754 + c.setFont("Helvetica", 8)
755 + c.setFillColor(INK2)
756 + c.drawString(M, y, ("Loyer médian par nombre de chambres : "
757 + + " · ".join(parts))[:118])
758 + y -= 13
759 + c.setFont("Helvetica-Bold", 8)
760 + c.setFillColor(INK)
761 + c.drawString(M, y, "Rotation des logements :")
762 + c.setFont("Helvetica", 8)
763 + c.setFillColor(INK2)
764 + if rot.get("statut") == "calculated":
765 + c.drawString(M + 118, y,
766 + (f"{rot.get('classe', '')} — {rot.get('annonces_12m')} "
767 + f"annonces sur 12 mois pour ≥{NBSP}"
768 + f"{rot.get('unites_estimees')} unités")[:100])
769 + else:
770 + c.drawString(M + 118, y, "données insuffisantes")
771 + y -= 12
772 + c.setFont("Helvetica-Bold", 8)
773 + c.setFillColor(INK)
774 + c.drawString(M, y, "Pression sur les loyers :")
775 + c.setFont("Helvetica", 8)
776 + c.setFillColor(INK2)
777 + if pres:
778 + v12 = pres.get("variation_12m") or 0
779 + c.drawString(M + 118, y,
780 + (f"{'+' if v12 > 0 else '−'}{abs(round(v12 * 100))}"
781 + f"{NBSP}% sur 12 mois — médiane des prix d'entrée "
782 + f"{_fmt_money(pres.get('mediane_12_24m'))} → "
783 + f"{_fmt_money(pres.get('mediane_12m'))} "
784 + f"({pres.get('n_12_24m')} vs {pres.get('n_12m')} annonces)")[:110])
785 + else:
786 + c.drawString(M + 118, y, "échantillon insuffisant")
787 + y -= 12
788 + c.setFont("Helvetica", 6.5)
789 + c.setFillColor(INK3)
790 + c.drawString(M, y - 2, (f"Immeuble suivi par Lou-Ka depuis "
791 + f"{_fmt_ts(imm.get('premiere_observation'))} — "
792 + "indicateurs calculés uniquement sur les annonces "
793 + "observées (pas un recensement du bâtiment).")[:135])
794 + y -= 16
795 +
796 + # --- Qui gère ce logement? (fiche Google associée + avis synchronisés)
797 + g_ = (gest or {}).get("google_maps")
798 + if gest and g_ and g_.get("statut") != "non_associe":
799 + avis_g = gest.get("avis") or {}
800 + dist_g = avis_g.get("distribution") or {}
801 + tot_g = sum(dist_g.values()) if dist_g else 0
802 + y = place(y, 108 + (5 * 11 if tot_g else 0))
803 + y = s.titre_section(y, "Qui gère ce logement?")
804 + c.setFont("Helvetica-Bold", 10.5)
805 + c.setFillColor(INK)
806 + c.drawString(M, y - 2, (gest.get("nom") or "")[:60])
807 + n_act = gest.get("annonces_actives") or 0
808 + c.setFont("Helvetica", 8)
809 + c.setFillColor(INK2)
810 + c.drawString(M + c.stringWidth((gest.get("nom") or "")[:60],
811 + "Helvetica-Bold", 10.5) + 10, y - 2,
812 + f"{n_act} annonce{'s' if n_act > 1 else ''} active"
813 + f"{'s' if n_act > 1 else ''} sur Lou-Ka")
814 + y -= 16
815 + if g_.get("note") is not None:
816 + note_t = f"{g_['note']:.1f}".replace(".", ",")
817 + s.pilule(M, y - 10, f"Note Google {note_t} / 5", LIME, INK)
818 + c.setFont("Helvetica", 8)
819 + c.setFillColor(INK2)
820 + c.drawString(M + 110, y - 5,
821 + (f"{g_.get('nombre_avis', 0)}{NBSP}avis Google — fiche "
822 + f"« {g_.get('nom', '')} »")[:90])
823 + y -= 22
824 + if tot_g:
825 + bw_g = 150.0
826 + for n_ in (5, 4, 3, 2, 1):
827 + cnt = dist_g.get(str(n_), 0)
828 + c.setFont("Courier-Bold", 7)
829 + c.setFillColor(INK2)
830 + c.drawString(M, y, f"{n_}*")
831 + c.setFillColor(SURFACE)
832 + c.setStrokeColor(INK)
833 + c.setLineWidth(0.8)
834 + c.rect(M + 22, y - 1, bw_g, 7, stroke=1, fill=1)
835 + if cnt:
836 + c.setFillColor(GREEN if n_ >= 4 else (LIME if n_ <= 2 else INK3))
837 + c.rect(M + 22, y - 1, bw_g * cnt / tot_g, 7, stroke=0, fill=1)
838 + c.setFont("Courier-Bold", 7)
839 + c.setFillColor(INK)
840 + c.drawString(M + 22 + bw_g + 6, y, str(cnt))
841 + y -= 11
842 + y -= 2
843 + stats_g = []
844 + if avis_g.get("moyenne_12m") is not None:
845 + stats_g.append(f"moyenne des 12 derniers mois : "
846 + f"{avis_g['moyenne_12m']:.1f}".replace(".", ",")
847 + + f" ({avis_g.get('n_12m')} avis)")
848 + if avis_g.get("tendance"):
849 + stats_g.append(avis_g["tendance"])
850 + if avis_g.get("plaintes_frequentes"):
851 + stats_g.append("plaintes récurrentes : "
852 + + ", ".join(avis_g["plaintes_frequentes"][:4]))
853 + if stats_g:
854 + y = s.paragraphe(M, y, " · ".join(stats_g), PAGE_W - 2 * M,
855 + taille=8, couleur=INK2, interligne=10,
856 + max_lignes=2)
857 + y -= 2
858 + conf_g = round((g_.get("confiance_association") or 0) * 100)
859 + y = s.paragraphe(M, y - 2, f"Fiche Google Maps associée automatiquement "
860 + f"(confiance {conf_g}{NBSP}%). Statistiques sur "
861 + f"les {avis_g.get('n', 0)} avis synchronisés dans "
862 + "la base Lou-Ka — échantillon des plus récents, "
863 + "pas une lecture humaine.",
864 + PAGE_W - 2 * M, taille=6.5, couleur=INK3,
865 + interligne=8.5, max_lignes=3)
866 + y -= 6
867 +
571 868 # --- Registre des loyers (déclarations citoyennes à proximité)
572 869 if rdl_d and rdl_d.get("n"):
573 870 comp = rdl_d.get("items") or []
@@ -815,6 +1112,44 @@ def fiche_pdf(uid: str) -> bytes | None:
815 1112 f"{lab} · {sub}" if sub else lab)
816 1113 y -= 62
817 1114
1115 + # --- Vie quotidienne en hiver (quotidien à pied, même à −20 °C)
1116 + if hiv:
1117 + det_h = hiv.get("detail") or []
1118 + y = place(y, 52 + len(det_h) * 11 + 14)
1119 + y = s.titre_section(y, "Vie quotidienne en hiver")
1120 + c.setFont("Helvetica-Bold", 17)
1121 + c.setFillColor(INK)
1122 + sc_t = str(hiv.get("score", "—"))
1123 + c.drawString(M, y - 8, sc_t)
1124 + s.pilule(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 10, y - 12,
1125 + (hiv.get("classe") or "").upper())
1126 + c.setFont("Helvetica", 8)
1127 + c.setFillColor(INK3)
1128 + c.drawString(M + c.stringWidth(sc_t, "Helvetica-Bold", 17) + 105, y - 7,
1129 + "quotidien à pied, même à −20 °C")
1130 + y -= 26
1131 + for cr_ in det_h:
1132 + c.setFont("Helvetica-Bold", 8)
1133 + c.setFillColor(INK)
1134 + c.drawString(M, y, (cr_.get("critere") or "")[:24])
1135 + c.setFont("Helvetica", 8)
1136 + c.setFillColor(INK2)
1137 + if cr_.get("distance_m") is not None:
1138 + val_h = f"≈{NBSP}{cr_.get('minutes')}{NBSP}min à pied"
1139 + if cr_.get("nom"):
1140 + val_h += f" ({cr_['nom']})"
1141 + else:
1142 + val_h = cr_.get("note") or "—"
1143 + c.drawString(M + 90, y, val_h[:80])
1144 + c.setFont("Courier-Bold", 7.5)
1145 + c.setFillColor(INK)
1146 + c.drawRightString(PAGE_W - M, y, str(cr_.get("score", "—")))
1147 + y -= 11
1148 + c.setFont("Helvetica", 6.5)
1149 + c.setFillColor(INK3)
1150 + c.drawString(M, y - 2, (hiv.get("methode") or "")[:135])
1151 + y -= 16
1152 +
818 1153 # --- Le quartier
819 1154 if quartier:
820 1155 y = place(y, 270)
821 1156