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>
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 | |