Aggiunge il dettaglio esteso di partita: formazioni e scontri diretti dal CSI

In Campionato > Storico partite ogni gara diventa cliccabile: verso /partita/$id
se collegata a un evento CrAPP, verso la nuova /partita-csi/$id altrimenti (nessuna
creazione automatica di eventi dal calendario CSI, quindi molte gare ne sono prive).

Il dettaglio aggiunge formazioni (titolari/panchina/staff), storico scontri diretti
e probabilità di vittoria calcolata dal CSI, letti on-demand da tre nuovi endpoint
per-partita (match-main/players/stats.php) dietro /api/public/csi-partita/$id, con
cache server per-partita separata da quella di /api/public/csi — è un dato aperto a
richiesta, non precaricato per tutti come classifica e partite.

Include anche logo squadra e metadati leggeri (girone, n° gara, arbitro, link al
referto ufficiale) già presenti nel JSON esistente, a costo zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:07:03 +02:00
co-authored by Claude Sonnet 5
parent c628997934
commit 5569d08b59
12 changed files with 1207 additions and 38 deletions
+148 -18
View File
@@ -1,7 +1,8 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
@@ -75,6 +76,12 @@ competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_numbe
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
@@ -82,6 +89,59 @@ gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_m
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
## Implementazione
@@ -89,33 +149,82 @@ disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone sotto)
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni gara cliccabile)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`.
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI: tre fetch in parallelo
(classifica girone, classifica Coppa, partite). Cache in memoria di 6 ore; in caso di errore
restituisce l'ultimo dato buono (`503` solo se non ne esiste uno). Se solo la Coppa fallisce
(`scarica(...).catch(() => "")`) la risposta resta comunque `200` con `classificaCoppa: []`:
è un dato supplementare, non blocca la classifica del girone.
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
@@ -130,8 +239,10 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
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`).
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. 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. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
@@ -195,11 +306,30 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError``null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.