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