diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md index 58709ef..18683bf 100644 --- a/docs/modules/collegamento-csi.md +++ b/docs/modules/collegamento-csi.md @@ -90,6 +90,34 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h) istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`). 4. **I risultati includono anche la Coppa**, non solo il girone di campionato. +5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e + `partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con + un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI + cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex + non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" + (`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta + cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`. + **Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`, + obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite + si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali. + **Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un + errore solo se *sia* la classifica *sia* le partite sono vuote insieme + (`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle + partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200` + con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di + `partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi + grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` — + distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in + programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia + anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica` + (`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver + cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati + alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la + pagina campionato molto meno. Il fix, quando succede, è isolato a + `partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi + cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche + `src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con + fixture nel nuovo formato. --- diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index c8ac93a..d20a008 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -43,8 +43,10 @@ smart (`notifiche-smart.ts`). ## Limiti noti -- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si - rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali. +- Le vittorie di campionato dipendono dal **JSON** delle partite del portale CSI (non dalla + classifica HTML): se quel parsing si rompe, questi tre obiettivi restano a 0% anche a fronte + di vittorie reali, **senza segnalazione d'errore** — vedi il dettaglio in + [Collegamento CSI § Limiti noti](collegamento-csi.md#limiti-noti). - I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a ogni stagione — con l'eccezione di "Continuità di squadra" (vedi sotto), il cui target ha un significato specifico e non va scalato come gli altri. diff --git a/src/lib/csi-core.ts b/src/lib/csi-core.ts index f2bf897..f0f1282 100644 --- a/src/lib/csi-core.ts +++ b/src/lib/csi-core.ts @@ -39,6 +39,8 @@ export type DatiCsi = { partite: PartitaCsi[]; girone: string; aggiornato: string; + /** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */ + formatoSospetto: boolean; }; /** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */ @@ -162,6 +164,20 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] { return partite.filter((p) => p.setNostri !== null && p.setLoro !== null); } +/** + * True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce + * in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non + * distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in + * programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è + * stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una + * stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo + * silenzioso — vedi "Limiti noti" in docs/modules/collegamento-csi.md. + */ +export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean { + if (!Array.isArray(eventiGrezzi)) return true; + return eventiGrezzi.length > 0 && partite.length === 0; +} + /** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */ export function matchDaPartitaCsi(p: PartitaCsi) { return { diff --git a/src/routes/api/public/csi.ts b/src/routes/api/public/csi.ts index c2751de..aa2f4fa 100644 --- a/src/routes/api/public/csi.ts +++ b/src/routes/api/public/csi.ts @@ -3,6 +3,7 @@ import { CSI_GIRONE, parseClassifica, partiteDaEventi, + partiteFormatoSospetto, urlClassifica, urlPartite, type DatiCsi, @@ -29,11 +30,29 @@ async function scarica(url: string): Promise { async function leggiCsi(): Promise { const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]); const classifica = parseClassifica(html); - const partite = partiteDaEventi(JSON.parse(json)); + const eventiGrezzi = JSON.parse(json); + const partite = partiteDaEventi(eventiGrezzi); if (classifica.length === 0 && partite.length === 0) { throw new Error("CSI: risposta senza classifica né partite"); } - return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() }; + // La classifica basta a evitare l'errore sopra, ma se solo le partite si rompono (formato di + // getEventsByTeamId.php cambiato) la route tornerebbe comunque 200 senza che nessuno se ne + // accorga: le vittorie degli obiettivi di squadra resterebbero ferme a 0% in silenzio. Oltre + // al log server, il flag arriva fino a `/classifica` (badge discreto) perché qualcuno se ne + // accorga anche senza guardare i log. + const formatoSospetto = partiteFormatoSospetto(eventiGrezzi, partite); + if (formatoSospetto) { + console.error( + "csi: il formato di getEventsByTeamId.php sembra cambiato, nessuna partita riconosciuta", + ); + } + return { + classifica, + partite, + girone: CSI_GIRONE, + aggiornato: new Date().toISOString(), + formatoSospetto, + }; } export const Route = createFileRoute("/api/public/csi")({ diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index 0171572..5321513 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { ChevronRight, RefreshCw } from "lucide-react"; +import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatData } from "@/lib/crapp-data"; import { PageHeader } from "@/components/crapp/ui-bits"; @@ -85,11 +85,24 @@ function Classifica() { const contenutoClassifica = useMemo( () => ( <> -
- - {csi - ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` - : "Dati CSI in arrivo"} +
+ {csi?.formatoSospetto ? ( + + ) : ( + + )} + {csi?.formatoSospetto + ? "Il portale CSI potrebbe aver cambiato formato: dati da verificare." + : csi + ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` + : "Dati CSI in arrivo"}
diff --git a/test/unit/csi-core.test.ts b/test/unit/csi-core.test.ts index a907b79..e83745e 100644 --- a/test/unit/csi-core.test.ts +++ b/test/unit/csi-core.test.ts @@ -7,6 +7,7 @@ import { isNostraSquadra, parseClassifica, partiteDaEventi, + partiteFormatoSospetto, partiteGiocate, urlClassifica, urlPartite, @@ -96,6 +97,34 @@ assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali"); assert.equal(partiteGiocate(partite).length, 2); assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash"); +// --- partiteFormatoSospetto: distingue "nessuna gara ancora" da "formato rotto" ------------- +assert.equal( + partiteFormatoSospetto(eventi, partite), + false, + "eventi grezzi presenti e riconosciuti: nessun sospetto", +); +assert.equal( + partiteFormatoSospetto([], []), + false, + "nessun evento grezzo: probabilmente solo una stagione senza gare, non un formato rotto", +); +assert.equal( + partiteFormatoSospetto("non è un array", []), + true, + "la risposta non è più un array: il formato è cambiato", +); +const eventiSenzaCampiRiconosciuti = [ + { id: 1, quando: "2025-11-12", squadraCasa: "C.R.A.P. Volley", squadraOspite: "AMCM" }, +]; +assert.equal( + partiteFormatoSospetto( + eventiSenzaCampiRiconosciuti, + partiteDaEventi(eventiSenzaCampiRiconosciuti), + ), + true, + "eventi presenti ma con campi rinominati: nessuno riconosciuto, sospetto fondato", +); + if (process.env["CSI_LIVE"]) { const [html, json] = await Promise.all([ fetch(urlClassifica()).then((r) => r.text()),