SPB Git

spb/fabri-ka Public

Agrégateur de produits québécois — www.fabri-ka.com

HTML 57.9% Python 18.6% TypeScript 15.6% CSS 7.8%

Rapport de marché PDF (téléchargeable) + page stats détaillée

- Générateur PDF reportlab : page couverture pleine page aux couleurs de la
  marque, KPIs, histogramme des prix, tableaux catégories/régions/plateformes,
  origine A–E, top boutiques, méthodologie — endpoint /api/report.pdf (cache)
- Bouton 'Télécharger le rapport PDF' sur la page /stats (en-tête + pied)
- Page /stats enrichie : rail Nouveautés, disponibilité, complétude des données,
  mini-barres dans le tableau catégories, % dans l'histogramme
- Nettoyage : 51 prix corrompus (parsing) purgés → prix moyen 327k$ → 293,73$
- Caps défensifs sur les moyennes/max de prix (<= 500 000 $)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 46 min ago (Aug 12, 2026) parent 020bb75

Showing 9 changed files with +1,013 and −8

modified README.md +1 −0
@@ -129,6 +129,7 @@ echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
129 129 | `GET /api/facets` | Catégories/régions/origines avec compteurs |
130 130 | `GET /api/stats` | Totaux, ventilation régionale, journal de sync |
131 131 | `GET /api/stats/extended` | Statistiques riches : prix (médian/moyen/histogramme), catégories, régions, plateformes, croissance, top boutiques |
132 +| `GET /api/report.pdf` | Rapport de marché PDF (couverture de marque, KPIs, graphiques, tableaux) — généré à la volée |
132 133 | `POST /api/sync` | Déclenche une synchronisation en arrière-plan |
133 134
134 135 ## Classification d'origine
modified docs/screenshots/stats.png +0 −0

Binary file not shown.

added fabrika/report.py +401 −0
@@ -0,0 +1,401 @@
1 +# -----------------------------------------------------------------------------
2 +# Fabri-Ka — Agrégateur de produits québécois
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# report.py : génération du « Rapport de marché Fabri-Ka » en PDF (reportlab).
5 +# Portrait complet du commerce en ligne québécois : couverture de marque,
6 +# KPIs, prix, catégories, régions, plateformes, origine, top boutiques.
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import io
11 +from datetime import date
12 +
13 +from reportlab.lib import colors
14 +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
15 +from reportlab.lib.pagesizes import A4
16 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
17 +from reportlab.lib.units import cm, mm
18 +from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, NextPageTemplate,
19 + PageBreak, PageTemplate, Paragraph, Spacer, Table,
20 + TableStyle)
21 +
22 +# Palette de marque
23 +CREAM = colors.HexColor("#FAF6F0")
24 +INK = colors.HexColor("#1E1B16")
25 +TERRA = colors.HexColor("#C4532E")
26 +PINE = colors.HexColor("#234438")
27 +SAND = colors.HexColor("#F1E9DD")
28 +BORDER = colors.HexColor("#E4D9C8")
29 +MUTE = colors.HexColor("#6B6257")
30 +
31 +ORIGIN_COLORS = {"A": PINE, "B": TERRA, "C": colors.HexColor("#8A6D3B"),
32 + "D": colors.HexColor("#5B5B8A"), "E": colors.HexColor("#999999")}
33 +ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec",
34 + "C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"}
35 +
36 +
37 +def _money(v):
38 + if v is None:
39 + return "—"
40 + try:
41 + return f"{float(v):,.2f} $".replace(",", " ").replace(".", ",")
42 + except (TypeError, ValueError):
43 + return "—"
44 +
45 +
46 +def _int(v):
47 + try:
48 + return f"{int(v):,}".replace(",", " ")
49 + except (TypeError, ValueError):
50 + return "0"
51 +
52 +
53 +class Logo(Flowable):
54 + """Logo Fabri·Ka vectoriel : pastille pin + monogramme, wordmark."""
55 +
56 + def __init__(self, size=18 * mm):
57 + super().__init__()
58 + self.size = size
59 + self.width = size
60 + self.height = size
61 +
62 + def draw(self):
63 + c = self.canv
64 + s = self.size
65 + c.setFillColor(PINE)
66 + c.roundRect(0, 0, s, s, s * 0.22, fill=1, stroke=0)
67 + c.setFillColor(CREAM)
68 + c.setFont("Helvetica-Bold", s * 0.5)
69 + c.drawCentredString(s / 2, s * 0.30, "F")
70 + c.setFillColor(TERRA)
71 + c.circle(s * 0.76, s * 0.72, s * 0.07, fill=1, stroke=0)
72 +
73 +
74 +class Bars(Flowable):
75 + """Barres horizontales (label, valeur) avec pourcentage du total."""
76 +
77 + def __init__(self, rows, width=17 * cm, bar_color=TERRA, unit="", max_rows=None,
78 + show_pct=True):
79 + super().__init__()
80 + self.rows = rows[:max_rows] if max_rows else rows
81 + self.width = width
82 + self.bar_color = bar_color
83 + self.unit = unit
84 + self.show_pct = show_pct
85 + self.row_h = 17
86 + self.height = self.row_h * len(self.rows) + 4
87 +
88 + def draw(self):
89 + c = self.canv
90 + if not self.rows:
91 + return
92 + mx = max((r[1] for r in self.rows), default=1) or 1
93 + total = sum(r[1] for r in self.rows) or 1
94 + label_w = 4.4 * cm
95 + val_w = 3.2 * cm
96 + track = self.width - label_w - val_w
97 + y = self.height - self.row_h
98 + for label, val in self.rows:
99 + c.setFillColor(INK)
100 + c.setFont("Helvetica", 8.5)
101 + c.drawString(0, y + 5, (label or "")[:30])
102 + c.setFillColor(SAND)
103 + c.roundRect(label_w, y + 2, track, 11, 2.5, fill=1, stroke=0)
104 + w = track * (val / mx)
105 + c.setFillColor(self.bar_color)
106 + c.roundRect(label_w, y + 2, max(w, 2), 11, 2.5, fill=1, stroke=0)
107 + c.setFillColor(INK)
108 + c.setFont("Helvetica-Bold", 8.5)
109 + txt = _int(val) + self.unit
110 + if self.show_pct:
111 + txt += f" {val / total * 100:.1f}%"
112 + c.drawRightString(self.width, y + 5, txt)
113 + y -= self.row_h
114 +
115 +
116 +class OriginBar(Flowable):
117 + """Barre segmentée proportionnelle A–E."""
118 +
119 + def __init__(self, rows, width=16 * cm):
120 + super().__init__()
121 + self.rows = [r for r in rows if r.get("products")]
122 + self.width = width
123 + self.height = 30
124 +
125 + def draw(self):
126 + c = self.canv
127 + total = sum(r["products"] for r in self.rows) or 1
128 + x = 0
129 + for r in self.rows:
130 + w = self.width * (r["products"] / total)
131 + c.setFillColor(ORIGIN_COLORS.get(r["key"], MUTE))
132 + c.rect(x, 8, w, 16, fill=1, stroke=0)
133 + if w > 20:
134 + c.setFillColor(colors.white)
135 + c.setFont("Helvetica-Bold", 9)
136 + c.drawCentredString(x + w / 2, 13, r["key"])
137 + x += w
138 +
139 +
140 +def _h(text, style):
141 + return Paragraph(text, style)
142 +
143 +
144 +def _cover_page(canvas, stats):
145 + """Page de couverture pleine page, sharp."""
146 + W, H = A4
147 + t = stats["totals"]
148 + _MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet",
149 + "août", "septembre", "octobre", "novembre", "décembre"]
150 + d = date.today()
151 + today = f"{d.day} {_MOIS[d.month]} {d.year}"
152 +
153 + canvas.saveState()
154 + # fond crème + large bande pin en bas
155 + canvas.setFillColor(CREAM)
156 + canvas.rect(0, 0, W, H, fill=1, stroke=0)
157 + canvas.setFillColor(PINE)
158 + canvas.rect(0, 0, W, 5.5 * cm, fill=1, stroke=0)
159 + canvas.setFillColor(TERRA)
160 + canvas.rect(0, 5.5 * cm, W, 0.15 * cm, fill=1, stroke=0)
161 +
162 + # logo pastille
163 + lx, ly, ls = 2.2 * cm, H - 4 * cm, 2.4 * cm
164 + canvas.setFillColor(PINE)
165 + canvas.roundRect(lx, ly, ls, ls, ls * 0.22, fill=1, stroke=0)
166 + canvas.setFillColor(CREAM)
167 + canvas.setFont("Helvetica-Bold", ls * 0.5)
168 + canvas.drawCentredString(lx + ls / 2, ly + ls * 0.30, "F")
169 + canvas.setFillColor(TERRA)
170 + canvas.circle(lx + ls * 0.76, ly + ls * 0.72, ls * 0.07, fill=1, stroke=0)
171 + canvas.setFillColor(INK)
172 + canvas.setFont("Helvetica-Bold", 30)
173 + canvas.drawString(lx + ls + 0.5 * cm, ly + ls * 0.42, "Fabri")
174 + fw = canvas.stringWidth("Fabri", "Helvetica-Bold", 30)
175 + canvas.setFillColor(TERRA)
176 + canvas.drawString(lx + ls + 0.5 * cm + fw, ly + ls * 0.42, "·Ka")
177 +
178 + # titre
179 + canvas.setFillColor(TERRA)
180 + canvas.setFont("Helvetica-Bold", 12)
181 + canvas.drawString(2.2 * cm, H - 7.4 * cm, "RAPPORT DE MARCHÉ")
182 + canvas.setFillColor(INK)
183 + canvas.setFont("Helvetica-Bold", 40)
184 + canvas.drawString(2.2 * cm, H - 9.3 * cm, "Le commerce")
185 + canvas.drawString(2.2 * cm, H - 10.9 * cm, "en ligne québécois")
186 + canvas.setFillColor(MUTE)
187 + canvas.setFont("Helvetica", 13)
188 + canvas.drawString(2.2 * cm, H - 12 * cm,
189 + f"Portrait du marché agrégé par Fabri-Ka — {today}")
190 +
191 + # chiffres clés en vedette sur la bande pin
192 + kpis = [(_int(t.get("products")), "produits"),
193 + (_int(t.get("stores_live")), "boutiques actives"),
194 + (f"{t.get('regions') or 0}/17", "régions"),
195 + (_money(t.get("price_median")), "prix médian")]
196 + cw = W / len(kpis)
197 + for i, (val, lab) in enumerate(kpis):
198 + cx = cw * i + cw / 2
199 + canvas.setFillColor(CREAM)
200 + canvas.setFont("Helvetica-Bold", 22)
201 + canvas.drawCentredString(cx, 3.1 * cm, str(val))
202 + canvas.setFillColor(colors.HexColor("#B9C7BE"))
203 + canvas.setFont("Helvetica", 9)
204 + canvas.drawCentredString(cx, 2.35 * cm, lab.upper())
205 +
206 + canvas.setFillColor(colors.HexColor("#9FB0A6"))
207 + canvas.setFont("Helvetica", 8)
208 + canvas.drawString(2.2 * cm, 1.1 * cm,
209 + "www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")
210 + canvas.restoreState()
211 +
212 +
213 +def build_report(stats: dict) -> bytes:
214 + buf = io.BytesIO()
215 + ss = getSampleStyleSheet()
216 +
217 + title = ParagraphStyle("t", parent=ss["Title"], fontName="Helvetica-Bold",
218 + fontSize=30, leading=34, textColor=INK, spaceAfter=2)
219 + sub = ParagraphStyle("s", fontName="Helvetica", fontSize=11, leading=15,
220 + textColor=MUTE)
221 + h2 = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15, leading=18,
222 + textColor=PINE, spaceBefore=14, spaceAfter=8)
223 + body = ParagraphStyle("b", fontName="Helvetica", fontSize=9.5, leading=14,
224 + textColor=INK)
225 + small = ParagraphStyle("sm", fontName="Helvetica", fontSize=8, leading=11,
226 + textColor=MUTE)
227 + eyebrow = ParagraphStyle("eb", fontName="Helvetica-Bold", fontSize=9,
228 + leading=12, textColor=TERRA)
229 +
230 + doc = BaseDocTemplate(buf, pagesize=A4,
231 + leftMargin=2 * cm, rightMargin=2 * cm,
232 + topMargin=1.6 * cm, bottomMargin=1.6 * cm,
233 + title="Rapport de marché Fabri-Ka",
234 + author="Simon-Pierre Boucher — Fabri-Ka")
235 + frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")
236 + cover_frame = Frame(0, 0, A4[0], A4[1], id="cover")
237 +
238 + def footer(canvas, d):
239 + canvas.saveState()
240 + canvas.setStrokeColor(BORDER)
241 + canvas.setLineWidth(0.5)
242 + canvas.line(doc.leftMargin, 1.3 * cm, A4[0] - doc.rightMargin, 1.3 * cm)
243 + canvas.setFont("Helvetica", 7.5)
244 + canvas.setFillColor(MUTE)
245 + canvas.drawString(doc.leftMargin, 0.95 * cm,
246 + "Fabri-Ka — www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")
247 + canvas.drawRightString(A4[0] - doc.rightMargin, 0.95 * cm,
248 + f"page {d.page - 1}")
249 + canvas.restoreState()
250 +
251 + doc.addPageTemplates([
252 + PageTemplate(id="cover", frames=[cover_frame],
253 + onPage=lambda c, d: _cover_page(c, stats)),
254 + PageTemplate(id="main", frames=[frame], onPage=footer),
255 + ])
256 +
257 + story = [NextPageTemplate("main"), PageBreak()]
258 + t = stats["totals"]
259 +
260 + # La page 1 est la couverture pleine page (dessinée par _cover_page).
261 + # Le contenu commence page 2.
262 + story.append(_h("APERÇU DU MARCHÉ", eyebrow))
263 + story.append(_h("Les chiffres clés", h2))
264 + story.append(_h(
265 + "Fabri-Ka recense les boutiques en ligne du Québec et agrège leurs "
266 + "catalogues publics (Shopify, WooCommerce, Wix, Squarespace), "
267 + "resynchronisés aux 6 heures. Ce rapport présente l'état du marché : "
268 + "volume de produits, boutiques actives, structure des prix, répartition "
269 + "par catégorie, région, plateforme et origine de fabrication (A–E).",
270 + body))
271 + story.append(Spacer(1, 10))
272 +
273 + # ---- KPI band
274 + kpis = [
275 + (_int(t.get("products")), "produits agrégés"),
276 + (_int(t.get("stores_live")), "boutiques actives"),
277 + (_int(t.get("stores_registry")), "boutiques au registre"),
278 + (str(t.get("regions") or 0) + " / 17", "régions couvertes"),
279 + (_money(t.get("price_median")), "prix médian"),
280 + (_money(t.get("price_avg")), "prix moyen"),
281 + ]
282 + cells = []
283 + row = []
284 + for i, (val, lab) in enumerate(kpis):
285 + card = Table([[Paragraph(f'<font size=15 color="#234438"><b>{val}</b></font>', body)],
286 + [Paragraph(f'<font size=8 color="#6B6257">{lab.upper()}</font>', small)]],
287 + colWidths=[5.1 * cm])
288 + card.setStyle(TableStyle([
289 + ("BACKGROUND", (0, 0), (-1, -1), colors.white),
290 + ("BOX", (0, 0), (-1, -1), 0.5, BORDER),
291 + ("TOPPADDING", (0, 0), (-1, -1), 6),
292 + ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
293 + ("LEFTPADDING", (0, 0), (-1, -1), 8)]))
294 + row.append(card)
295 + if len(row) == 3:
296 + cells.append(row); row = []
297 + if row:
298 + cells.append(row)
299 + kpi_table = Table(cells, colWidths=[5.4 * cm] * 3, hAlign="LEFT")
300 + kpi_table.setStyle(TableStyle([("TOPPADDING", (0, 0), (-1, -1), 3),
301 + ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
302 + ("LEFTPADDING", (0, 0), (-1, -1), 0)]))
303 + story.append(kpi_table)
304 +
305 + # ---- Structure des prix
306 + story.append(_h("Structure des prix", h2))
307 + story.append(Bars([(b["bucket"] + " $", b["n"]) for b in stats.get("price_buckets", [])],
308 + bar_color=TERRA))
309 +
310 + # ---- Catégories (tableau)
311 + story.append(_h("Marché par catégorie", h2))
312 + cat_rows = [["Catégorie", "Produits", "Boutiques", "Prix médian", "Prix moyen"]]
313 + for c in stats.get("by_category", [])[:18]:
314 + cat_rows.append([c["label"], _int(c["products"]), _int(c["stores"]),
315 + _money(c.get("price_median")), _money(c.get("price_avg"))])
316 + cat = Table(cat_rows, colWidths=[5.5 * cm, 2.4 * cm, 2.4 * cm, 3 * cm, 3 * cm])
317 + cat.setStyle(TableStyle([
318 + ("BACKGROUND", (0, 0), (-1, 0), SAND),
319 + ("TEXTCOLOR", (0, 0), (-1, 0), INK),
320 + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
321 + ("FONTSIZE", (0, 0), (-1, -1), 8.5),
322 + ("ALIGN", (1, 0), (-1, -1), "RIGHT"),
323 + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
324 + ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
325 + ("TOPPADDING", (0, 0), (-1, -1), 4),
326 + ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
327 + story.append(cat)
328 +
329 + story.append(PageBreak())
330 +
331 + # ---- Régions
332 + story.append(_h("Marché par région administrative", h2))
333 + story.append(Bars([(r["key"], r["products"]) for r in stats.get("by_region", [])],
334 + bar_color=PINE))
335 +
336 + # ---- Origine
337 + story.append(_h("Origine des produits (classes A–E)", h2))
338 + story.append(OriginBar(stats.get("by_origin", [])))
339 + story.append(Spacer(1, 4))
340 + leg = []
341 + for o in stats.get("by_origin", []):
342 + hexcol = "#" + ORIGIN_COLORS.get(o["key"], MUTE).hexval()[2:]
343 + leg.append(Paragraph(
344 + f'<font color="{hexcol}">■</font> <b>{o["key"]}</b> '
345 + f'{ORIGIN_LABELS.get(o["key"], o["key"])} — '
346 + f'{_int(o.get("products"))} produits, {_int(o.get("stores"))} boutiques', small))
347 + story += leg
348 +
349 + # ---- Plateformes
350 + story.append(_h("Plateformes ecommerce", h2))
351 + plat_rows = [["Plateforme", "Boutiques", "Produits"]]
352 + for p in stats.get("by_platform", [])[:12]:
353 + plat_rows.append([p["key"], _int(p["stores"]), _int(p.get("products"))])
354 + plat = Table(plat_rows, colWidths=[6 * cm, 5 * cm, 5 * cm])
355 + plat.setStyle(TableStyle([
356 + ("BACKGROUND", (0, 0), (-1, 0), SAND),
357 + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
358 + ("FONTSIZE", (0, 0), (-1, -1), 8.5),
359 + ("ALIGN", (1, 0), (-1, -1), "RIGHT"),
360 + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
361 + ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
362 + ("TOPPADDING", (0, 0), (-1, -1), 4),
363 + ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
364 + story.append(plat)
365 +
366 + # ---- Top boutiques
367 + story.append(_h("Boutiques les mieux garnies", h2))
368 + top_rows = [["#", "Boutique", "Région", "Origine", "Produits"]]
369 + for i, s in enumerate(stats.get("top_stores", [])[:15], 1):
370 + top_rows.append([str(i), (s["name"] or s["id"])[:38], s.get("region") or "—",
371 + s.get("origin_class") or "—", _int(s.get("products"))])
372 + top = Table(top_rows, colWidths=[1 * cm, 6.5 * cm, 4 * cm, 1.8 * cm, 2.7 * cm])
373 + top.setStyle(TableStyle([
374 + ("BACKGROUND", (0, 0), (-1, 0), SAND),
375 + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
376 + ("FONTSIZE", (0, 0), (-1, -1), 8.5),
377 + ("ALIGN", (0, 0), (0, -1), "CENTER"),
378 + ("ALIGN", (3, 0), (-1, -1), "RIGHT"),
379 + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
380 + ("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
381 + ("TOPPADDING", (0, 0), (-1, -1), 4),
382 + ("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
383 + story.append(top)
384 +
385 + # ---- Note méthodo
386 + story.append(_h("Méthodologie & portée", h2))
387 + cov = stats.get("coverage", {})
388 + story.append(_h(
389 + "Les données proviennent des catalogues publics des boutiques "
390 + "(Shopify, WooCommerce, Wix, Squarespace) agrégés automatiquement et "
391 + "resynchronisés aux 6 heures. Les prix et disponibilités appartiennent "
392 + "aux marchands sources. La classification d'origine A–E s'appuie sur les "
393 + "certifications (Aliments du Québec, Les Produits du Québec), l'adhésion "
394 + "à des associations de producteurs et les mentions des sites — jamais sur "
395 + "la seule présomption qu'une entreprise est québécoise. "
396 + f"Couverture : {_int(cov.get('with_image'))} produits avec image, "
397 + f"{_int(cov.get('with_logo'))} boutiques avec logo, "
398 + f"{_int(cov.get('with_region'))} boutiques géolocalisées.", body))
399 +
400 + doc.build(story)
401 + return buf.getvalue()
modified fabrika/web.py +35 −5
@@ -8,6 +8,7 @@ from __future__ import annotations
8 8 import json
9 9 import sqlite3
10 10 import threading
11 +from datetime import date
11 12 from pathlib import Path
12 13
13 14 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
@@ -223,8 +224,8 @@ def stats_extended():
223 224 for r in q(con, """SELECT category AS key, COUNT(*) AS products,
224 225 COUNT(DISTINCT store_id) AS stores,
225 226 MIN(CASE WHEN price>0 THEN price END) AS price_min,
226 ROUND(AVG(CASE WHEN price>0 THEN price END),2) AS price_avg,
227 MAX(price) AS price_max
227 + ROUND(AVG(CASE WHEN price>0 AND price<=500000 THEN price END),2) AS price_avg,
228 + MAX(CASE WHEN price<=500000 THEN price END) AS price_max
228 229 FROM products WHERE active=1 GROUP BY category
229 230 ORDER BY products DESC"""):
230 231 r["label"] = CATEGORIES.get(r["key"], (r["key"], []))[0]
@@ -255,7 +256,7 @@ def stats_extended():
255 256 ORDER BY products DESC""")
256 257 by_region = q(con, """SELECT s.region AS key, COUNT(DISTINCT s.id) AS stores,
257 258 COUNT(p.uid) AS products,
258 ROUND(AVG(CASE WHEN p.price>0 THEN p.price END),2) AS price_avg
259 + ROUND(AVG(CASE WHEN p.price>0 AND p.price<=500000 THEN p.price END),2) AS price_avg
259 260 FROM stores s LEFT JOIN products p
260 261 ON p.store_id=s.id AND p.active=1
261 262 WHERE s.region<>'' GROUP BY s.region ORDER BY products DESC""")
@@ -270,26 +271,55 @@ def stats_extended():
270 271 FROM products p JOIN stores s ON s.id=p.store_id
271 272 WHERE p.active=1 AND p.price>0
272 273 ORDER BY p.price DESC LIMIT 8""")
274 + newest = q(con, """SELECT p.uid, p.title, p.price, p.category, s.name AS store_name
275 + FROM products p JOIN stores s ON s.id=p.store_id
276 + WHERE p.active=1 ORDER BY p.first_seen DESC LIMIT 12""")
277 + for r in newest:
278 + r["category_label"] = CATEGORIES.get(r["category"], (r["category"], []))[0]
279 + availability = q(con, """SELECT CASE WHEN available=1 THEN 'en stock'
280 + WHEN available=0 THEN 'rupture'
281 + ELSE 'inconnu' END AS key, COUNT(*) AS n
282 + FROM products WHERE active=1 GROUP BY key""")
283 + coverage = q(con, """SELECT
284 + (SELECT COUNT(*) FROM products WHERE active=1 AND images<>'[]' AND images IS NOT NULL) AS with_image,
285 + (SELECT COUNT(*) FROM products WHERE active=1 AND description<>'') AS with_desc,
286 + (SELECT COUNT(*) FROM stores WHERE logo_url IS NOT NULL AND logo_url<>'') AS with_logo,
287 + (SELECT COUNT(*) FROM stores WHERE region<>'') AS with_region
288 + """)[0]
273 289 totals = q(con, """SELECT
274 290 (SELECT COUNT(*) FROM products WHERE active=1) AS products,
275 291 (SELECT COUNT(*) FROM products WHERE active=1 AND price>0) AS products_priced,
276 292 (SELECT COUNT(*) FROM stores WHERE product_count>0) AS stores_live,
277 293 (SELECT COUNT(*) FROM stores) AS stores_registry,
278 294 (SELECT COUNT(DISTINCT region) FROM stores WHERE region<>'') AS regions,
279 (SELECT ROUND(AVG(price),2) FROM products WHERE active=1 AND price>0) AS price_avg
295 + (SELECT ROUND(AVG(price),2) FROM products WHERE active=1 AND price>0 AND price<=500000) AS price_avg
280 296 """)[0]
281 297 totals["price_median"] = global_median
282 298 data = {"totals": totals, "by_category": by_category,
283 299 "price_buckets": price_buckets, "by_origin": by_origin,
284 300 "by_platform": by_platform, "by_region": by_region,
285 301 "growth": growth, "top_stores": top_stores,
286 "most_expensive": most_expensive}
302 + "most_expensive": most_expensive, "newest": newest,
303 + "availability": availability, "coverage": coverage}
287 304 _stats_cache.update(ts=_time.time(), data=data)
288 305 return data
289 306 finally:
290 307 con.close()
291 308
292 309
310 +@app.get("/api/report.pdf")
311 +def report_pdf():
312 + """Rapport de marché Fabri-Ka en PDF (logo, KPIs, prix, catégories,
313 + régions, origine, plateformes, top boutiques)."""
314 + from fastapi.responses import Response
315 + from . import report
316 + stats = stats_extended()
317 + pdf = report.build_report(stats)
318 + fname = f"rapport-marche-fabri-ka-{date.today()}.pdf"
319 + return Response(content=pdf, media_type="application/pdf",
320 + headers={"Content-Disposition": f'attachment; filename="{fname}"'})
321 +
322 +
293 323 @app.get("/api/stats")
294 324 def stats():
295 325 con = db.connect()
modified frontend/src/api.ts +26 −0
@@ -218,6 +218,29 @@ export interface ExpensiveProduct {
218 218 store_name: string
219 219 }
220 220
221 +export interface NewestProduct {
222 + uid: string
223 + title: string
224 + price: number | null
225 + category: string | null
226 + category_label: string
227 + store_name: string
228 +}
229 +
230 +export type AvailabilityKey = 'en stock' | 'rupture' | 'inconnu'
231 +
232 +export interface AvailabilityStat {
233 + key: AvailabilityKey
234 + n: number
235 +}
236 +
237 +export interface Coverage {
238 + with_image: number
239 + with_desc: number
240 + with_logo: number
241 + with_region: number
242 +}
243 +
221 244 export interface ExtendedStats {
222 245 totals: ExtendedTotals
223 246 by_category: CategoryStat[]
@@ -228,6 +251,9 @@ export interface ExtendedStats {
228 251 growth: GrowthPoint[]
229 252 top_stores: TopStore[]
230 253 most_expensive: ExpensiveProduct[]
254 + newest: NewestProduct[]
255 + availability: AvailabilityStat[]
256 + coverage: Coverage
231 257 }
232 258
233 259 // ---------------------------------------------------------------------------
modified frontend/src/components/Icons.tsx +10 −0
@@ -211,6 +211,16 @@ export function IconFacebook(props: IconProps) {
211 211 )
212 212 }
213 213
214 +export function IconDownload(props: IconProps) {
215 + return (
216 + <Svg {...props}>
217 + <path d="M12 3.5v11" />
218 + <path d="m7.5 10 4.5 4.5 4.5-4.5" />
219 + <path d="M4.5 19.5h15" />
220 + </Svg>
221 + )
222 +}
223 +
214 224 export function IconTag(props: IconProps) {
215 225 return (
216 226 <Svg {...props}>
modified frontend/src/pages/Stats.tsx +228 −3
@@ -12,6 +12,7 @@ import {
12 12 } from '../api'
13 13 import CountUp from '../components/CountUp'
14 14 import EmptyState from '../components/EmptyState'
15 +import { IconDownload } from '../components/Icons'
15 16 import OriginBadge from '../components/OriginBadge'
16 17 import Skeleton from '../components/Skeleton'
17 18 import StoreLogo from '../components/StoreLogo'
@@ -52,6 +53,19 @@ const BUCKET_LABELS: Record<string, string> = {
52 53 '1000+': '1 000 $ et plus',
53 54 }
54 55
56 +const REPORT_URL = '/api/report.pdf'
57 +const REPORT_HINT = 'Rapport de marché complet — PDF, mise à jour en continu'
58 +
59 +// Availability keys → French label + CSS slug (pine / muted / border)
60 +const AVAILABILITY_META: Record<
61 + string,
62 + { label: string; slug: string; order: number }
63 +> = {
64 + 'en stock': { label: 'En stock', slug: 'stock', order: 0 },
65 + rupture: { label: 'En rupture', slug: 'rupture', order: 1 },
66 + inconnu: { label: 'Inconnu', slug: 'inconnu', order: 2 },
67 +}
68 +
55 69 // ---------------------------------------------------------------------------
56 70 // Growth — hand-rolled SVG area chart (terracotta line on sand fill)
57 71 // ---------------------------------------------------------------------------
@@ -214,6 +228,44 @@ export default function Stats() {
214 228 })
215 229 : []
216 230 const originTotal = originRows.reduce((sum, r) => sum + r.stat.products, 0)
231 + const catMax = stats
232 + ? Math.max(...stats.by_category.map((c) => c.products), 1)
233 + : 1
234 +
235 + const availabilityRows = stats
236 + ? [...stats.availability]
237 + .filter((a) => AVAILABILITY_META[a.key])
238 + .sort(
239 + (a, b) => AVAILABILITY_META[a.key].order - AVAILABILITY_META[b.key].order
240 + )
241 + : []
242 + const availabilityTotal = availabilityRows.reduce((sum, a) => sum + a.n, 0)
243 +
244 + const coverageMeters =
245 + stats && totals
246 + ? [
247 + {
248 + label: 'Produits avec image',
249 + value: stats.coverage.with_image,
250 + total: totals.products,
251 + },
252 + {
253 + label: 'Produits avec description',
254 + value: stats.coverage.with_desc,
255 + total: totals.products,
256 + },
257 + {
258 + label: 'Boutiques avec logo',
259 + value: stats.coverage.with_logo,
260 + total: totals.stores_registry,
261 + },
262 + {
263 + label: 'Boutiques géolocalisées',
264 + value: stats.coverage.with_region,
265 + total: totals.stores_registry,
266 + },
267 + ].map((m) => ({ ...m, pct: m.total ? m.value / m.total : 0 }))
268 + : []
217 269
218 270 return (
219 271 <div className="page stats">
@@ -225,8 +277,21 @@ export default function Stats() {
225 277 </h1>
226 278 <p className="stats-hero-sub">
227 279 Portrait généré à partir des catalogues publics agrégés par Fabri-Ka —
228 données au {generatedOn}, recalculées en continu.
280 + données au {generatedOn}, recalculées en continu. Chaque chiffre est
281 + vivant : il se met à jour à mesure que les boutiques se synchronisent.
282 + Pour le détail complet, téléchargez le rapport de marché.
229 283 </p>
284 + <div className="stats-hero-actions">
285 + <a
286 + className="btn btn-primary stats-report-btn"
287 + href={REPORT_URL}
288 + download
289 + >
290 + <IconDownload size={18} />
291 + Télécharger le rapport PDF
292 + </a>
293 + <span className="stats-report-hint">{REPORT_HINT}</span>
294 + </div>
230 295 </section>
231 296
232 297 {/* 2 — KPI tiles */}
@@ -238,36 +303,50 @@ export default function Stats() {
238 303 <CountUp value={totals.products} />
239 304 </span>
240 305 <span className="stats-kpi-label">Produits</span>
306 + <span className="stats-kpi-sub">
307 + {formatInt(totals.products_priced)} avec prix affiché
308 + </span>
241 309 </div>
242 310 <div className="stats-kpi">
243 311 <span className="stats-kpi-value">
244 312 <CountUp value={totals.stores_live} />
245 313 </span>
246 314 <span className="stats-kpi-label">Boutiques actives</span>
315 + <span className="stats-kpi-sub">
316 + {formatShare(totals.stores_live, totals.stores_registry) ||
317 + '—'}{' '}
318 + du registre
319 + </span>
247 320 </div>
248 321 <div className="stats-kpi">
249 322 <span className="stats-kpi-value">
250 323 <CountUp value={totals.stores_registry} />
251 324 </span>
252 325 <span className="stats-kpi-label">Boutiques au registre</span>
326 + <span className="stats-kpi-sub">boutiques suivies</span>
253 327 </div>
254 328 <div className="stats-kpi">
255 329 <span className="stats-kpi-value">
256 330 <CountUp value={totals.regions} />
257 331 </span>
258 332 <span className="stats-kpi-label">Régions</span>
333 + <span className="stats-kpi-sub">du Québec couvertes</span>
259 334 </div>
260 335 <div className="stats-kpi">
261 336 <span className="stats-kpi-value">
262 337 {formatPrice(totals.price_median) || '—'}
263 338 </span>
264 339 <span className="stats-kpi-label">Prix médian</span>
340 + <span className="stats-kpi-sub">
341 + sur {formatInt(totals.products_priced)} produits
342 + </span>
265 343 </div>
266 344 <div className="stats-kpi">
267 345 <span className="stats-kpi-value">
268 346 {formatPrice(totals.price_avg) || '—'}
269 347 </span>
270 348 <span className="stats-kpi-label">Prix moyen</span>
349 + <span className="stats-kpi-sub">panier type</span>
271 350 </div>
272 351 </>
273 352 ) : (
@@ -275,6 +354,7 @@ export default function Stats() {
275 354 <div className="stats-kpi" key={i}>
276 355 <Skeleton width="80px" height="2rem" />
277 356 <Skeleton width="110px" height="0.8rem" />
357 + <Skeleton width="90px" height="0.7rem" />
278 358 </div>
279 359 ))
280 360 )}
@@ -288,7 +368,39 @@ export default function Stats() {
288 368 </div>
289 369 ) : (
290 370 <>
291 {/* 3 — price histogram */}
371 + {/* 3 — nouveautés du marché */}
372 + {stats.newest.length > 0 && (
373 + <section className="stats-section" aria-label="Nouveautés du marché">
374 + <header className="section-header">
375 + <h2>Nouveautés du marché</h2>
376 + <Link className="section-see-all" to="/produits?sort=recent">
377 + Voir les récents
378 + </Link>
379 + </header>
380 + <div className="stats-newest-rail">
381 + {stats.newest.map((p) => (
382 + <Link
383 + key={p.uid}
384 + className="card stats-newest-card"
385 + to={`/produits/${encodeURIComponent(p.uid)}`}
386 + >
387 + <span className="stats-newest-chip">
388 + {p.category_label || 'Divers'}
389 + </span>
390 + <span className="stats-newest-title">{p.title}</span>
391 + <span className="stats-newest-foot">
392 + <span className="stats-newest-price">
393 + {formatPrice(p.price) || 'Prix n.d.'}
394 + </span>
395 + <span className="stats-newest-store">{p.store_name}</span>
396 + </span>
397 + </Link>
398 + ))}
399 + </div>
400 + </section>
401 + )}
402 +
403 + {/* 4 — price histogram */}
292 404 {stats.price_buckets.length > 0 && totals && (
293 405 <section className="stats-section" aria-label="Répartition des prix">
294 406 <header className="section-header">
@@ -316,6 +428,11 @@ export default function Stats() {
316 428 </div>
317 429 ))}
318 430 </div>
431 + <p className="stats-hist-caption">
432 + Prix médian{' '}
433 + <strong>{formatPrice(totals.price_median) || '—'}</strong> · prix
434 + moyen <strong>{formatPrice(totals.price_avg) || '—'}</strong>
435 + </p>
319 436 </section>
320 437 )}
321 438
@@ -353,7 +470,24 @@ export default function Stats() {
353 470 {c.label}
354 471 </Link>
355 472 </td>
356 <td className="num">{formatInt(c.products)}</td>
473 + <td className="num stats-cat-prod">
474 + <span className="stats-cat-prod-inner">
475 + <span
476 + className="stats-cat-prod-bar"
477 + aria-hidden="true"
478 + >
479 + <span
480 + className="stats-cat-prod-fill"
481 + style={{
482 + width: `${(c.products / catMax) * 100}%`,
483 + }}
484 + />
485 + </span>
486 + <span className="stats-cat-prod-n">
487 + {formatInt(c.products)}
488 + </span>
489 + </span>
490 + </td>
357 491 <td className="num">{formatInt(c.stores)}</td>
358 492 <td className="num">{formatPrice(c.price_median) || '—'}</td>
359 493 <td className="num">{formatPrice(c.price_avg) || '—'}</td>
@@ -526,6 +660,84 @@ export default function Stats() {
526 660 </section>
527 661 )}
528 662
663 + {/* disponibilité */}
664 + {availabilityRows.length > 0 && availabilityTotal > 0 && (
665 + <section className="stats-section" aria-label="Disponibilité">
666 + <header className="section-header">
667 + <h2>Disponibilité</h2>
668 + </header>
669 + <div
670 + className="stats-avail-bar"
671 + role="img"
672 + aria-label={availabilityRows
673 + .map(
674 + (a) =>
675 + `${AVAILABILITY_META[a.key].label} : ${formatInt(a.n)} produits`
676 + )
677 + .join(' · ')}
678 + >
679 + {availabilityRows
680 + .filter((a) => a.n > 0)
681 + .map((a) => (
682 + <span
683 + key={a.key}
684 + className={`stats-avail-seg stats-avail-${AVAILABILITY_META[a.key].slug}`}
685 + style={{ width: `${(a.n / availabilityTotal) * 100}%` }}
686 + title={`${AVAILABILITY_META[a.key].label} — ${formatInt(a.n)} (${formatShare(a.n, availabilityTotal)})`}
687 + />
688 + ))}
689 + </div>
690 + <ul className="stats-avail-legend">
691 + {availabilityRows.map((a) => (
692 + <li key={a.key}>
693 + <span
694 + className={`stats-avail-dot stats-avail-${AVAILABILITY_META[a.key].slug}`}
695 + aria-hidden="true"
696 + />
697 + <span>
698 + {AVAILABILITY_META[a.key].label}{' '}
699 + <strong>{formatInt(a.n)}</strong>{' '}
700 + <em>{formatShare(a.n, availabilityTotal)}</em>
701 + </span>
702 + </li>
703 + ))}
704 + </ul>
705 + </section>
706 + )}
707 +
708 + {/* complétude des données */}
709 + {coverageMeters.length > 0 && (
710 + <section
711 + className="stats-section"
712 + aria-label="Complétude des données"
713 + >
714 + <header className="section-header">
715 + <h2>Complétude des données</h2>
716 + </header>
717 + <div className="stats-coverage">
718 + {coverageMeters.map((m) => (
719 + <div className="stats-meter" key={m.label}>
720 + <div className="stats-meter-head">
721 + <span className="stats-meter-label">{m.label}</span>
722 + <span className="stats-meter-pct">
723 + {percentFormatter.format(m.pct)}
724 + </span>
725 + </div>
726 + <span className="stats-meter-track">
727 + <span
728 + className="stats-meter-fill"
729 + style={{ width: `${m.pct * 100}%` }}
730 + />
731 + </span>
732 + <span className="stats-meter-sub">
733 + {formatInt(m.value)} / {formatInt(m.total)}
734 + </span>
735 + </div>
736 + ))}
737 + </div>
738 + </section>
739 + )}
740 +
529 741 <div className="stats-columns">
530 742 {/* 9 — top stores */}
531 743 {stats.top_stores.length > 0 && (
@@ -593,6 +805,19 @@ export default function Stats() {
593 805 </div>
594 806 </>
595 807 )}
808 +
809 + {/* report download — footer */}
810 + <section className="stats-report-footer" aria-label="Rapport de marché">
811 + <a
812 + className="btn btn-secondary stats-report-btn"
813 + href={REPORT_URL}
814 + download
815 + >
816 + <IconDownload size={18} />
817 + Télécharger le rapport PDF
818 + </a>
819 + <span className="stats-report-hint">{REPORT_HINT}</span>
820 + </section>
596 821 </div>
597 822 )
598 823 }
modified frontend/src/styles.css +311 −0
@@ -3223,6 +3223,317 @@ a.card:active {
3223 3223 white-space: nowrap;
3224 3224 }
3225 3225
3226 +/* --------------------------------------------------------------------------
3227 + Stats — report download button
3228 + -------------------------------------------------------------------------- */
3229 +
3230 +.stats-hero-actions {
3231 + margin-top: 1.25rem;
3232 + display: flex;
3233 + flex-direction: column;
3234 + gap: 0.55rem;
3235 + align-items: flex-start;
3236 +}
3237 +
3238 +.stats-report-btn {
3239 + width: 100%;
3240 +}
3241 +
3242 +.stats-report-hint {
3243 + font-size: 0.8rem;
3244 + color: var(--muted);
3245 +}
3246 +
3247 +@media (min-width: 640px) {
3248 + .stats-report-btn {
3249 + width: auto;
3250 + }
3251 +}
3252 +
3253 +.stats-report-footer {
3254 + margin: 3rem 0 1rem;
3255 + padding-top: 1.9rem;
3256 + border-top: 1px solid var(--border);
3257 + display: flex;
3258 + flex-direction: column;
3259 + gap: 0.55rem;
3260 + align-items: center;
3261 + text-align: center;
3262 +}
3263 +
3264 +@media (min-width: 640px) {
3265 + .stats-report-footer .stats-report-btn {
3266 + width: auto;
3267 + }
3268 +}
3269 +
3270 +/* KPI sub-labels */
3271 +.stats-kpi-sub {
3272 + font-size: 0.72rem;
3273 + color: var(--muted);
3274 + font-variant-numeric: tabular-nums;
3275 + line-height: 1.3;
3276 +}
3277 +
3278 +/* Price histogram caption */
3279 +.stats-hist-caption {
3280 + margin: 1rem 0 0;
3281 + font-size: 0.9rem;
3282 + color: var(--muted);
3283 +}
3284 +
3285 +.stats-hist-caption strong {
3286 + color: var(--pine);
3287 + font-variant-numeric: tabular-nums;
3288 +}
3289 +
3290 +/* Category table — inline mini bar in the Produits cell */
3291 +.stats-cat-prod-inner {
3292 + display: inline-flex;
3293 + align-items: center;
3294 + justify-content: flex-end;
3295 + gap: 0.5rem;
3296 +}
3297 +
3298 +.stats-cat-prod-bar {
3299 + width: 46px;
3300 + height: 6px;
3301 + border-radius: 3px;
3302 + background: var(--sand);
3303 + overflow: hidden;
3304 + flex: 0 0 auto;
3305 +}
3306 +
3307 +.stats-cat-prod-fill {
3308 + display: block;
3309 + height: 100%;
3310 + border-radius: 3px;
3311 + background: var(--accent);
3312 +}
3313 +
3314 +.stats-cat-prod-n {
3315 + min-width: 3ch;
3316 + text-align: right;
3317 + font-variant-numeric: tabular-nums;
3318 +}
3319 +
3320 +/* Nouveautés — horizontal snap rail */
3321 +.stats-newest-rail {
3322 + display: flex;
3323 + gap: 0.75rem;
3324 + overflow-x: auto;
3325 + scroll-snap-type: x proximity;
3326 + -webkit-overflow-scrolling: touch;
3327 + scrollbar-width: none;
3328 + margin: 0 -1rem;
3329 + padding: 0.25rem 1rem 0.75rem;
3330 +}
3331 +
3332 +.stats-newest-rail::-webkit-scrollbar {
3333 + display: none;
3334 +}
3335 +
3336 +.stats-newest-card {
3337 + flex: 0 0 190px;
3338 + scroll-snap-align: start;
3339 + display: flex;
3340 + flex-direction: column;
3341 + gap: 0.5rem;
3342 + padding: 0.85rem 0.9rem;
3343 + min-height: 138px;
3344 +}
3345 +
3346 +.stats-newest-chip {
3347 + align-self: flex-start;
3348 + max-width: 100%;
3349 + font-size: 0.66rem;
3350 + font-weight: 600;
3351 + text-transform: uppercase;
3352 + letter-spacing: 0.06em;
3353 + color: var(--accent);
3354 + background: var(--sand);
3355 + border-radius: 999px;
3356 + padding: 0.2rem 0.55rem;
3357 + white-space: nowrap;
3358 + overflow: hidden;
3359 + text-overflow: ellipsis;
3360 +}
3361 +
3362 +.stats-newest-title {
3363 + font-weight: 600;
3364 + font-size: 0.92rem;
3365 + line-height: 1.3;
3366 + color: var(--ink);
3367 + display: -webkit-box;
3368 + -webkit-line-clamp: 2;
3369 + line-clamp: 2;
3370 + -webkit-box-orient: vertical;
3371 + overflow: hidden;
3372 +}
3373 +
3374 +.stats-newest-foot {
3375 + margin-top: auto;
3376 + display: flex;
3377 + flex-direction: column;
3378 + gap: 0.1rem;
3379 + min-width: 0;
3380 +}
3381 +
3382 +.stats-newest-price {
3383 + font-family: var(--font-display);
3384 + font-weight: 800;
3385 + color: var(--pine);
3386 + font-variant-numeric: tabular-nums;
3387 +}
3388 +
3389 +.stats-newest-store {
3390 + font-size: 0.78rem;
3391 + color: var(--muted);
3392 + white-space: nowrap;
3393 + overflow: hidden;
3394 + text-overflow: ellipsis;
3395 +}
3396 +
3397 +@media (hover: hover) {
3398 + .stats-newest-card:hover {
3399 + border-color: var(--accent);
3400 + box-shadow: var(--shadow-hover);
3401 + text-decoration: none;
3402 + }
3403 +}
3404 +
3405 +@media (min-width: 768px) {
3406 + .stats-newest-rail {
3407 + margin: 0;
3408 + padding-left: 0;
3409 + padding-right: 0;
3410 + scrollbar-width: thin;
3411 + }
3412 +}
3413 +
3414 +/* Disponibilité — segmented bar */
3415 +.stats-avail-bar {
3416 + display: flex;
3417 + height: 14px;
3418 + border-radius: 999px;
3419 + overflow: hidden;
3420 + background: var(--sand);
3421 + max-width: 520px;
3422 +}
3423 +
3424 +.stats-avail-seg {
3425 + display: block;
3426 + height: 100%;
3427 +}
3428 +
3429 +.stats-avail-stock {
3430 + background: var(--pine);
3431 +}
3432 +
3433 +.stats-avail-rupture {
3434 + background: var(--muted);
3435 +}
3436 +
3437 +.stats-avail-inconnu {
3438 + background: var(--border);
3439 +}
3440 +
3441 +.stats-avail-legend {
3442 + list-style: none;
3443 + margin: 0.8rem 0 0;
3444 + padding: 0;
3445 + display: flex;
3446 + flex-wrap: wrap;
3447 + gap: 0.4rem 1.4rem;
3448 +}
3449 +
3450 +.stats-avail-legend li {
3451 + display: inline-flex;
3452 + align-items: center;
3453 + gap: 0.45rem;
3454 + font-size: 0.86rem;
3455 + color: var(--muted);
3456 +}
3457 +
3458 +.stats-avail-dot {
3459 + width: 10px;
3460 + height: 10px;
3461 + border-radius: 3px;
3462 + flex: 0 0 auto;
3463 +}
3464 +
3465 +.stats-avail-legend strong {
3466 + color: var(--ink);
3467 + font-variant-numeric: tabular-nums;
3468 +}
3469 +
3470 +.stats-avail-legend em {
3471 + font-style: normal;
3472 + color: var(--muted);
3473 +}
3474 +
3475 +/* Complétude — mini progress meters */
3476 +.stats-coverage {
3477 + display: grid;
3478 + grid-template-columns: 1fr;
3479 + gap: 1rem 1.6rem;
3480 +}
3481 +
3482 +@media (min-width: 640px) {
3483 + .stats-coverage {
3484 + grid-template-columns: repeat(2, 1fr);
3485 + }
3486 +}
3487 +
3488 +.stats-meter {
3489 + display: flex;
3490 + flex-direction: column;
3491 + gap: 0.35rem;
3492 + min-width: 0;
3493 +}
3494 +
3495 +.stats-meter-head {
3496 + display: flex;
3497 + justify-content: space-between;
3498 + align-items: baseline;
3499 + gap: 0.5rem;
3500 +}
3501 +
3502 +.stats-meter-label {
3503 + font-size: 0.88rem;
3504 + font-weight: 500;
3505 + color: var(--ink);
3506 +}
3507 +
3508 +.stats-meter-pct {
3509 + font-family: var(--font-display);
3510 + font-weight: 800;
3511 + color: var(--pine);
3512 + font-variant-numeric: tabular-nums;
3513 +}
3514 +
3515 +.stats-meter-track {
3516 + display: block;
3517 + height: 8px;
3518 + border-radius: 999px;
3519 + background: var(--sand);
3520 + overflow: hidden;
3521 +}
3522 +
3523 +.stats-meter-fill {
3524 + display: block;
3525 + height: 100%;
3526 + border-radius: 999px;
3527 + background: var(--accent);
3528 + transition: width 0.6s ease;
3529 +}
3530 +
3531 +.stats-meter-sub {
3532 + font-size: 0.76rem;
3533 + color: var(--muted);
3534 + font-variant-numeric: tabular-nums;
3535 +}
3536 +
3226 3537 /* --------------------------------------------------------------------------
3227 3538 Reduced motion
3228 3539 -------------------------------------------------------------------------- */
modified requirements.txt +1 −0
@@ -3,3 +3,4 @@ fastapi>=0.110
3 3 uvicorn>=0.29
4 4 requests>=2.31
5 5 beautifulsoup4>=4.12
6 +reportlab>=4.0
6 7