SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

Phase 2 — affichage & API : bandeaux statut, heures de fin, quarantaine, santé par source

- Frontend : badge Annulé/Reporté/Complet sur les cartes, bandeau de statut
  sur la fiche, plage horaire « 19 h 30 – 21 h 30 » quand end_time est connue,
  heure 00:00 affichée « heure non confirmée » (jamais un minuit menteur).
- SEO/SSR : eventStatus schema.org (EventCancelled/EventPostponed), offers
  availability SoldOut, endDate horodatée (endDate + end_time + fuseau),
  bandeau statut dans le HTML SSR ; sitemaps sans les événements en
  quarantaine.
- API : /api/events, /api/stats et le tableau de bord excluent la
  quarantaine ; /api/stats expose « quarantined » ; /api/sources expose
  upcoming_events par source + future_alerts (canal de supervision — une
  source verte sans futur est morte en silence).

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

4 changed files +83 −22

modified frontend/index.html +26 −2
@@ -182,6 +182,16 @@ button{font-family:inherit}
182 182 .badge-price{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
183 183 color:var(--ink);background:var(--amber-soft);border:1px solid var(--amber);
184 184 border-radius:var(--r-ctl);padding:3px 8px;flex:none}
185 +.badge-status{font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;
186 + color:#fff;background:#b3261e;border:1px solid var(--ink);
187 + border-radius:var(--r-ctl);padding:4px 8px;flex:none}
188 +.badge-status--soldout{background:#5f6368}
189 +.badge-status--postponed{background:#a05a00}
190 +.status-banner{margin:0 0 14px;padding:10px 14px;border:1.5px solid var(--ink);
191 + border-radius:var(--r-ctl);font-weight:700;color:#fff;background:#b3261e;
192 + box-shadow:2px 2px 0 var(--ink)}
193 +.status-banner--soldout{background:#5f6368}
194 +.status-banner--postponed{background:#a05a00}
185 195 .card h3{font-size:17px;font-weight:700;line-height:1.25}
186 196 .card .desc{font-size:12.5px;color:var(--ink-2);display:-webkit-box;
187 197 -webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
@@ -448,6 +458,7 @@ button{font-family:inherit}
448 458 <div class="img" id="d-img"></div>
449 459 <div class="body">
450 460 <span class="klabel" id="d-cats-label"></span>
461 + <div id="d-status"></div>
451 462 <h1 id="d-title"></h1>
452 463 <div class="desc" id="d-desc"></div>
453 464 <div class="detail-cats" id="d-cats"></div>
@@ -644,10 +655,18 @@ function dateBadge(ev, full=false){
644 655 if(!ev.start_date) return "Date à confirmer";
645 656 if(ev.end_date && ev.end_date !== ev.start_date)
646 657 return `${fmtDate(ev.start_date, full)} → ${fmtDate(ev.end_date, full)}`;
658 + if(ev.start_time === "00:00") /* minuit = suspect, jamais affiché tel quel */
659 + return `${fmtDate(ev.start_date, full)} · heure non confirmée`;
647 660 return fmtDate(ev.start_date, full)
648 − + (ev.start_time ? ` · ${fmtTime(ev.start_time)}` : "");
661 + + (ev.start_time ? ` · ${fmtTime(ev.start_time)}`
662 + + (ev.end_time && ev.end_time !== ev.start_time ? ` – ${fmtTime(ev.end_time)}` : "") : "");
649 663 }
650 664 function esc(s){ const d=document.createElement("div"); d.textContent=s||""; return d.innerHTML; }
665 +const STATUS_LABELS = {cancelled:"Annulé", postponed:"Reporté", soldout:"Complet"};
666 +function statusBadge(ev){
667 + if(!ev.status || !STATUS_LABELS[ev.status]) return "";
668 + return `<span class="badge-status badge-status--${esc(ev.status)}">${STATUS_LABELS[ev.status]}</span>`;
669 +}
651 670 function priceBadge(ev){
652 671 if(ev.is_free === true) return `<span class="badge-free">Gratuit</span>`;
653 672 if(ev.price_min != null) return `<span class="badge-price">Dès ${ev.price_min.toFixed(2).replace(".",",")} $</span>`;
@@ -665,7 +684,7 @@ function card(ev){
665 684 return `<a class="card gk-card gk-hover" data-nav href="/evenement/${esc(ev.uid)}">
666 685 <div class="card-img">${img}<span class="badge-date">${dateBadge(ev)}</span></div>
667 686 <div class="card-body">
668 − <div class="card-top"><h3>${esc(ev.title)}</h3>${priceBadge(ev)}</div>
687 + <div class="card-top"><h3>${esc(ev.title)}</h3>${statusBadge(ev)||priceBadge(ev)}</div>
669 688 ${ev.description?`<div class="desc">${esc(ev.description)}</div>`:""}
670 689 <div class="where">${where||"Lieu à confirmer"}</div>
671 690 <div class="cats">${cats}</div>
@@ -728,6 +747,11 @@ async function showDetail(uid){
728 747 ? `<img src="${esc(ev.image)}" alt="" onerror="this.remove()">` : "";
729 748 $("d-cats-label").textContent = (ev.categories||[]).filter(c=>c!=="autre")
730 749 .map(c=>CAT_LABELS[c]||c).join(" · ") || "Événement";
750 + $("d-status").innerHTML = ev.status && STATUS_LABELS[ev.status]
751 + ? `<div class="status-banner status-banner--${esc(ev.status)}">${
752 + ev.status==="cancelled" ? "Cet événement est annulé"
753 + : ev.status==="postponed" ? "Cet événement est reporté — vérifiez la nouvelle date chez la source"
754 + : "Complet — plus de billets disponibles"}</div>` : "";
731 755 $("d-title").textContent = ev.title;
732 756 $("d-desc").textContent = ev.description || "";
733 757 $("d-cats").innerHTML = (ev.raw_categories||[]).slice(0,5).filter(c=>c.length<40)
modified sortika/seo.py +23 −4
@@ -90,12 +90,18 @@ def _event_or_404(uid: str) -> dict:
90 90 return d
91 91
92 92
93 +# statut interne → eventStatus schema.org (soldout n'existe pas comme
94 +# eventStatus : il se déclare via offers.availability SoldOut)
95 +_LD_STATUS = {"cancelled": "EventCancelled", "postponed": "EventPostponed"}
96 +
97 +
93 98 def _jsonld(ev: dict) -> dict:
94 99 ld: dict = {
95 100 "@context": "https://schema.org",
96 101 "@type": "Event",
97 102 "name": ev["title"],
98 − "eventStatus": "https://schema.org/EventScheduled",
103 + "eventStatus": "https://schema.org/"
104 + + _LD_STATUS.get(ev.get("status") or "", "EventScheduled"),
99 105 "url": f"{BASE_URL}/evenement/{ev['uid']}",
100 106 }
101 107 if ev.get("start_date"):
@@ -104,6 +110,8 @@ def _jsonld(ev: dict) -> dict:
104 110 ld["startDate"] += f"T{ev['start_time']}:00{_tz_offset(ev['start_date'])}"
105 111 if ev.get("end_date"):
106 112 ld["endDate"] = ev["end_date"]
113 + if ev.get("end_time"): # heure de fin connue → ISO 8601 complet
114 + ld["endDate"] += f"T{ev['end_time']}:00{_tz_offset(ev['end_date'])}"
107 115 artists = json.loads(ev.get("artists") or "[]") \
108 116 if isinstance(ev.get("artists"), str) else (ev.get("artists") or [])
109 117 if artists:
@@ -131,11 +139,17 @@ def _jsonld(ev: dict) -> dict:
131 139 if ev.get("price_min") is not None:
132 140 ld["offers"] = {"@type": "Offer", "price": f"{ev['price_min']:.2f}",
133 141 "priceCurrency": "CAD", "url": ev.get("url") or ""}
142 + if (ev.get("status") or "") == "soldout":
143 + ld["offers"]["availability"] = "https://schema.org/SoldOut"
134 144 if ev.get("organizer"):
135 145 ld["organizer"] = {"@type": "Organization", "name": ev["organizer"]}
136 146 return ld
137 147
138 148
149 +_STATUS_LABELS = {"cancelled": "Annulé", "postponed": "Reporté",
150 + "soldout": "Complet"}
151 +
152 +
139 153 @router.get("/evenement/{uid}", include_in_schema=False)
140 154 def event_page(uid: str) -> HTMLResponse:
141 155 ev = _event_or_404(uid)
@@ -146,6 +160,9 @@ def event_page(uid: str) -> HTMLResponse:
146 160 when += f" au {ev['end_date']}"
147 161 elif ev.get("start_time"):
148 162 when += f" à {ev['start_time'].replace(':', ' h ')}"
163 + if ev.get("end_time"):
164 + when += f" – {ev['end_time'].replace(':', ' h ')}"
165 + status_label = _STATUS_LABELS.get(ev.get("status") or "", "")
149 166 title = f"{ev['title']} — {ev.get('city') or 'Québec'} | {SITE_NAME}"
150 167 desc = (ev.get("description") or
151 168 f"{ev['title']}, {where}. Dates, lieu et lien vers la billetterie "
@@ -170,7 +187,8 @@ def event_page(uid: str) -> HTMLResponse:
170 187 src = _SRC_LABELS.get(ev["source"], ev["source"])
171 188 ssr = (
172 189 f"<article><h1>{_e(ev['title'])}</h1>"
173 − f"<p>{_e(when)} — {_e(where)}</p>"
190 + + (f"<p><strong>{_e(status_label)}</strong></p>" if status_label else "")
191 + + f"<p>{_e(when)} — {_e(where)}</p>"
174 192 + (f"<p>{_e(ev['description'][:500])}</p>" if ev.get("description") else "")
175 193 + f'<p>Source : {_e(src)} — <a href="{_e(ev["url"])}" rel="noopener">'
176 194 f"fiche originale et billets</a></p>"
@@ -266,7 +284,8 @@ def _urlset(rows: list[tuple[str, str | None]]) -> Response:
266 284 @router.get("/sitemap.xml", include_in_schema=False)
267 285 def sitemap_index() -> Response:
268 286 con = db.connect()
269 − n = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]
287 + n = con.execute("SELECT COUNT(*) FROM events WHERE active=1 "
288 + "AND quarantine IS NULL").fetchone()[0]
270 289 con.close()
271 290 chunks = max(1, -(-n // SITEMAP_CHUNK))
272 291 items = "".join(
@@ -291,7 +310,7 @@ def sitemap_events(num: int) -> Response:
291 310 con = db.connect()
292 311 rows = con.execute(
293 312 "SELECT uid, updated_at FROM events WHERE active=1 "
294 − "ORDER BY uid LIMIT ? OFFSET ?",
313 + "AND quarantine IS NULL ORDER BY uid LIMIT ? OFFSET ?",
295 314 (SITEMAP_CHUNK, num * SITEMAP_CHUNK)).fetchall()
296 315 con.close()
297 316 def iso(ts):
modified sortika/stats.py +9 −9
@@ -119,7 +119,7 @@ def _ongoing_counts(con, start: date, days: int) -> list[int]:
119 119 horizon = start + timedelta(days=days - 1)
120 120 for r in con.execute(
121 121 "SELECT start_date, COALESCE(end_date, start_date) AS e FROM events "
122 − "WHERE active=1 AND start_date IS NOT NULL "
122 + "WHERE active=1 AND quarantine IS NULL AND start_date IS NOT NULL "
123 123 "AND start_date <= ? AND COALESCE(end_date, start_date) >= ?",
124 124 (horizon.isoformat(), start.isoformat())):
125 125 s = max(_parse_iso(r["start_date"]) or start, start)
@@ -136,7 +136,7 @@ def _ongoing_counts(con, start: date, days: int) -> list[int]:
136 136
137 137
138 138 def _upcoming_where() -> str:
139 − return ("active=1 AND (end_date >= :today OR "
139 + return ("active=1 AND quarantine IS NULL AND (end_date >= :today OR "
140 140 "(end_date IS NULL AND start_date >= :today))")
141 141
142 142
@@ -175,7 +175,7 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
175 175 tp = {"today": today.isoformat()}
176 176
177 177 # ---------- KPI ----------
178 − active = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]
178 + active = con.execute("SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NULL").fetchone()[0]
179 179 upcoming = con.execute(
180 180 f"SELECT COUNT(*) FROM events WHERE {_upcoming_where()}", tp).fetchone()[0]
181 181 free_up = con.execute(
@@ -189,13 +189,13 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
189 189 "SELECT COUNT(*) FROM events WHERE start_date IS NOT NULL "
190 190 "AND COALESCE(end_date, start_date) < :today", tp).fetchone()[0]
191 191 cities = con.execute(
192 − "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"
192 + "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND quarantine IS NULL AND city != ''"
193 193 ).fetchone()[0]
194 194 venues_up = con.execute(
195 195 f"SELECT COUNT(DISTINCT venue) FROM events WHERE {_upcoming_where()} "
196 196 "AND venue != ''", tp).fetchone()[0]
197 197 n_sources = con.execute(
198 − "SELECT COUNT(DISTINCT source) FROM events WHERE active=1").fetchone()[0]
198 + "SELECT COUNT(DISTINCT source) FROM events WHERE active=1 AND quarantine IS NULL").fetchone()[0]
199 199
200 200 added_map = _added_by_day(con, prev_from, p_to)
201 201 added_cur = sum(v for d, v in added_map.items()
@@ -208,7 +208,7 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
208 208 # + désactivés depuis (updated_at du passage active=0),
209 209 # seulement s'ils existaient déjà avant la période
210 210 added_active = con.execute(
211 − "SELECT COUNT(*) FROM events WHERE active=1 AND "
211 + "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NULL AND "
212 212 "date(first_seen,'unixepoch','localtime') >= ?",
213 213 (p_from.isoformat(),)).fetchone()[0]
214 214 deactivated = con.execute(
@@ -286,7 +286,7 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
286 286 "unit": "événements", "kind": "line", "points": spark_up})
287 287
288 288 starts_by_day = {r[0]: r[1] for r in con.execute(
289 − "SELECT start_date, COUNT(*) FROM events WHERE active=1 "
289 + "SELECT start_date, COUNT(*) FROM events WHERE active=1 AND quarantine IS NULL "
290 290 "AND start_date >= ? GROUP BY start_date", (today.isoformat(),))}
291 291 series.append({
292 292 "id": "debuts", "title":
@@ -414,7 +414,7 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
414 414 "items": [{"label": SRC_LABELS.get(r["source"], r["source"]),
415 415 "value": r["n"]}
416 416 for r in con.execute(
417 − "SELECT source, COUNT(*) AS n FROM events WHERE active=1 "
417 + "SELECT source, COUNT(*) AS n FROM events WHERE active=1 AND quarantine IS NULL "
418 418 "GROUP BY source ORDER BY n DESC")]},
419 419 ]
420 420 if reg_cur:
@@ -530,7 +530,7 @@ def dashboard(period: str = "30j", dfrom: str = "", dto: str = "") -> dict:
530 530 "GROUP BY source")}
531 531 src_rows = []
532 532 for r in con.execute(
533 − "SELECT source, COUNT(*) AS n FROM events WHERE active=1 "
533 + "SELECT source, COUNT(*) AS n FROM events WHERE active=1 AND quarantine IS NULL "
534 534 "GROUP BY source ORDER BY n DESC"):
535 535 s = r["source"]
536 536 ts = last_sync.get(s)
modified sortika/web.py +25 −7
@@ -64,7 +64,9 @@ def list_events(
64 64 limit: int = Query(30, ge=1, le=200),
65 65 offset: int = Query(0, ge=0),
66 66 ) -> dict:
67 − where, params = ["active=1"], []
67 + # actifs et hors quarantaine (règles anti-aberrations Phase 2 — un
68 + # événement quarantainé reste en base et réintègre dès la donnée saine)
69 + where, params = ["active=1", "quarantine IS NULL"], []
68 70 if q:
69 71 where.append("(title LIKE ? OR venue LIKE ? OR description LIKE ? OR city LIKE ?)")
70 72 like = f"%{q}%"
@@ -140,29 +142,33 @@ def get_event(uid: str) -> dict:
140 142 @app.get("/api/stats")
141 143 def stats() -> dict:
142 144 today = date.today().isoformat()
145 + pub = "active=1 AND quarantine IS NULL" # publiables seulement
143 146 con = db.connect()
144 − total = con.execute("SELECT COUNT(*) FROM events WHERE active=1").fetchone()[0]
147 + total = con.execute(f"SELECT COUNT(*) FROM events WHERE {pub}").fetchone()[0]
148 + quarantined = con.execute(
149 + "SELECT COUNT(*) FROM events WHERE active=1 AND quarantine IS NOT NULL"
150 + ).fetchone()[0]
145 151 upcoming = con.execute(
146 − "SELECT COUNT(*) FROM events WHERE active=1 AND "
152 + f"SELECT COUNT(*) FROM events WHERE {pub} AND "
147 153 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",
148 154 (today, today)).fetchone()[0]
149 155 free = con.execute(
150 − "SELECT COUNT(*) FROM events WHERE active=1 AND is_free=1 AND "
156 + f"SELECT COUNT(*) FROM events WHERE {pub} AND is_free=1 AND "
151 157 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",
152 158 (today, today)).fetchone()[0]
153 159 by_region = {r["region"] or "Non rattachée": r["n"] for r in con.execute(
154 − "SELECT region, COUNT(*) AS n FROM events WHERE active=1 AND "
160 + f"SELECT region, COUNT(*) AS n FROM events WHERE {pub} AND "
155 161 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?)) "
156 162 "GROUP BY region ORDER BY n DESC", (today, today))}
157 163 by_category: dict[str, int] = {}
158 164 for r in con.execute(
159 − "SELECT categories FROM events WHERE active=1 AND "
165 + f"SELECT categories FROM events WHERE {pub} AND "
160 166 "(end_date >= ? OR (end_date IS NULL AND start_date >= ?))",
161 167 (today, today)):
162 168 for c in json.loads(r["categories"] or "[]"):
163 169 by_category[c] = by_category.get(c, 0) + 1
164 170 cities = con.execute(
165 − "SELECT COUNT(DISTINCT city) FROM events WHERE active=1 AND city != ''"
171 + f"SELECT COUNT(DISTINCT city) FROM events WHERE {pub} AND city != ''"
166 172 ).fetchone()[0]
167 173 con.close()
168 174 # nombre de sources branchées (statut actif du registre) — lu par le hub
@@ -174,6 +180,7 @@ def stats() -> dict:
174 180 except Exception:
175 181 n_sources = 0
176 182 return {"total_active": total, "upcoming": upcoming, "free_upcoming": free,
183 + "quarantined": quarantined,
177 184 "sources": n_sources,
178 185 "cities": cities, "by_region": by_region,
179 186 "by_category": dict(sorted(by_category.items(),
@@ -228,13 +235,24 @@ def sources() -> dict:
228 235 con = db.connect()
229 236 counts = {r["source"]: r["n"] for r in con.execute(
230 237 "SELECT source, COUNT(*) AS n FROM events WHERE active=1 GROUP BY source")}
238 + # santé Phase 2 : événements FUTURS par source (une source « verte » sans
239 + # futur est morte en silence — canal lu par la supervision connecteurs)
240 + upcoming = db.future_counts(con)
231 241 last = {r["source"]: r["ts"] for r in con.execute(
232 242 "SELECT source, MAX(ts) AS ts FROM sync_log WHERE error IS NULL "
233 243 "GROUP BY source")}
234 244 con.close()
235 245 for s in reg.get("sources", []):
236 246 s["active_events"] = counts.get(s["id"], 0)
247 + s["upcoming_events"] = upcoming.get(s["id"], 0)
237 248 s["last_sync"] = last.get(s["id"])
249 + # alertes « futurs » du dernier cycle d'ingestion (data/future_counts.json)
250 + try:
251 + state = json.loads((ROOT / "data" / "future_counts.json")
252 + .read_text(encoding="utf-8"))
253 + reg["future_alerts"] = state.get("_alerts", [])
254 + except Exception:
255 + reg["future_alerts"] = []
238 256 return reg
239 257
240 258
241 259