From 5569d08b5929b61fab0e66d88aa0cebc5f4c0f8a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 9 Sep 2026 12:07:03 +0200 Subject: [PATCH] Aggiunge il dettaglio esteso di partita: formazioni e scontri diretti dal CSI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/modules/collegamento-csi.md | 166 +++++++++++-- src/components/crapp/DettaglioCsi.tsx | 283 +++++++++++++++++++++++ src/lib/csi-core.ts | 201 ++++++++++++++++ src/lib/csi-partita.ts | 21 ++ src/routeTree.gen.ts | 42 ++++ src/routes/api/public/csi-partita.$id.ts | 63 +++++ src/routes/classifica.tsx | 52 +++-- src/routes/partita-csi.$id.tsx | 118 ++++++++++ src/routes/partita.$id.tsx | 25 ++ test/e2e/percorsi.test.ts | 16 +- test/integration/api.test.ts | 46 +++- test/unit/csi-core.test.ts | 212 ++++++++++++++++- 12 files changed, 1207 insertions(+), 38 deletions(-) create mode 100644 src/components/crapp/DettaglioCsi.tsx create mode 100644 src/lib/csi-partita.ts create mode 100644 src/routes/api/public/csi-partita.$id.ts create mode 100644 src/routes/partita-csi.$id.tsx diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md index 0fad59b..0261615 100644 --- a/docs/modules/collegamento-csi.md +++ b/docs/modules/collegamento-csi.md @@ -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=`, 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=` | HTML | Giornata e una nota libera sotto l'impianto | +| `components/match-players.php?match_id=` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre | +| `components/match-stats.php?match_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 `
`, separati nel markup dal commento ``, ciascuno con +una `
    ` 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. diff --git a/src/components/crapp/DettaglioCsi.tsx b/src/components/crapp/DettaglioCsi.tsx new file mode 100644 index 0000000..f7ba10c --- /dev/null +++ b/src/components/crapp/DettaglioCsi.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; +import { ExternalLink, ShieldAlert, Users2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Card } from "@/components/crapp/ui-bits"; +import { useCsiPartita } from "@/lib/csi-partita"; +import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core"; + +/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */ +export function LogoSquadra({ + src, + alt, + className, +}: { + src: string; + alt: string; + className?: string; +}) { + const [errore, setErrore] = useState(false); + if (!src || errore) return null; + return ( + {alt} setErrore(true)} + className={cn("shrink-0 rounded-lg object-contain", className)} + /> + ); +} + +/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */ +export function MetaPartitaCsi({ + girone, + numeroGara, + arbitro, + link, +}: { + girone: string; + numeroGara: string; + arbitro: string; + link: string; +}) { + const voci = [ + girone, + numeroGara ? `N° gara ${numeroGara}` : "", + arbitro ? `Arbitro: ${arbitro}` : "", + ] + .filter(Boolean) + .join(" · "); + if (!voci && !link) return null; + return ( +
    + {voci ? {voci} : null} + {link ? ( + + Referto ufficiale CSI + + ) : null} +
    + ); +} + +function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) { + return ( +
    +

    + {squadra.squadra} +

    +
    + {squadra.titolari.map((g, i) => ( +
    + + {g.numero} + + {g.nome} + {g.ruolo ? {g.ruolo} : null} +
    + ))} +
    + {squadra.panchina.length > 0 ? ( + <> +

    + A disposizione +

    +
    + {squadra.panchina.map((g, i) => ( +
    + {g.numero} + {g.nome} +
    + ))} +
    + + ) : null} + {squadra.staff.length > 0 ? ( +
    + {squadra.staff.map((s, i) => ( +
    + {s.nome} · {s.ruolo} +
    + ))} +
    + ) : null} +
    + ); +} + +function BarraPrecedenti({ + label, + noi, + avversario, +}: { + label: string; + noi: number; + avversario: number; +}) { + const totale = noi + avversario || 1; + return ( +
    +
    + {noi} + {label} + {avversario} +
    +
    +
    +
    +
    + ); +} + +const NOME_NOI = "CRAP Volley"; + +/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */ +function TestataSquadre({ avversario }: { avversario: string }) { + return ( +
    + {NOME_NOI} + {avversario} +
    + ); +} + +function BarraProbabilita({ + avversario, + noi, + avversarioPct, +}: { + avversario: string; + noi: number; + avversarioPct: number; +}) { + const favoritaNoi = noi >= avversarioPct; + return ( +
    +

    + Probabilità di vittoria (calcolo CSI):{" "} + {favoritaNoi ? NOME_NOI : avversario}{" "} + favorita al {Math.max(noi, avversarioPct).toFixed(0)}%. +

    +
    + {NOME_NOI} + + {avversario} + +
    +
    +
    + {noi.toFixed(0)}% +
    +
    + {avversarioPct.toFixed(0)}% +
    +
    +
    + ); +} + +function BloccoPrecedenti({ + precedenti, + avversario, +}: { + precedenti: PrecedentiCsi; + avversario: string; +}) { + return ( +
    + +

    + {precedenti.totale > 0 + ? `${precedenti.totale} precedenti in archivio sul portale CSI.` + : "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."} +

    + {precedenti.totale > 0 ? ( + <> + + + + + ) : null} + {precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? ( + + ) : null} +
    + ); +} + +/** + * Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato + * server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una + * lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve + * solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI). + */ +export function DettaglioCsiEsteso({ + matchId, + avversario, +}: { + matchId: string; + avversario: string; +}) { + const { data, isLoading } = useCsiPartita(matchId); + + if (isLoading) { + return

    Carico i dati dal CSI…

    ; + } + if (!data || (!data.formazioni && !data.precedenti)) return null; + + return ( +
    + {data.giornata || data.nota ? ( +

    + {[data.giornata, data.nota].filter(Boolean).join(" · ")} +

    + ) : null} + {data.formazioni ? ( + +
    + Formazioni +
    +
    + + +
    +
    + ) : null} + {data.precedenti ? ( + +
    + Scontri diretti +
    + +
    + ) : null} +
    + ); +} diff --git a/src/lib/csi-core.ts b/src/lib/csi-core.ts index 76e5a39..54e9dbc 100644 --- a/src/lib/csi-core.ts +++ b/src/lib/csi-core.ts @@ -25,12 +25,23 @@ export const urlClassifica = (projectId = CSI_PROJECT_ID) => `${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`; export const urlPartite = (teamId = CSI_TEAM_ID) => `${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`; +/** Info generali della gara (giornata, pubblico ammesso). */ +export const urlPartitaInfo = (matchId: string) => + `${CSI_BASE}/components/match-main.php?match_id=${matchId}`; +/** Formazioni titolari/panchina/staff di entrambe le squadre. */ +export const urlPartitaFormazioni = (matchId: string) => + `${CSI_BASE}/components/match-players.php?match_id=${matchId}`; +/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */ +export const urlPartitaPrecedenti = (matchId: string) => + `${CSI_BASE}/components/match-stats.php?match_id=${matchId}`; export type PartitaCsi = { id: string; data: string; ora: string; avversario: string; + /** URL del logo avversario sul portale CSI, vuoto se non presente. */ + logoAvversario: string; casa: boolean; /** null finché la gara non è stata giocata. */ setNostri: number | null; @@ -38,6 +49,49 @@ export type PartitaCsi = { parziali: Array<[number, number]>; campo: string; competizione: string; + /** Es. "Girone B". */ + girone: string; + /** Es. "5/XEB". */ + numeroGara: string; + arbitro: string; + /** Referto ufficiale su livescore.csibologna.it. */ + link: string; +}; + +export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string }; +export type StaffFormazione = { nome: string; ruolo: string }; +export type FormazioneSquadra = { + squadra: string; + titolari: GiocatoreFormazione[]; + panchina: GiocatoreFormazione[]; + staff: StaffFormazione[]; +}; + +export type PrecedentiCsi = { + totale: number; + vinteNoi: number; + vinteAvversario: number; + casaNoi: number; + casaAvversario: number; + fuoriNoi: number; + fuoriAvversario: number; + /** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */ + probabilitaNoi: number | null; + probabilitaAvversario: number | null; +}; + +export type DettaglioPartitaCsi = { + /** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */ + giornata: string; + /** + * Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome + * della palestra o altro — non ha un formato fisso, va mostrato così com'è. Vuoto se assente. + */ + nota: string; + /** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */ + formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null; + /** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */ + precedenti: PrecedentiCsi | null; }; export type DatiCsi = { @@ -117,11 +171,17 @@ type EventoCsi = { id?: number | string; start?: string; team1?: string; + team1_logo?: string; team2?: string; + team2_logo?: string; result?: string; partials?: string; field?: string; project?: string; + group?: string; + match_number?: string; + referees?: string; + link?: string; }; function punteggio(result: string | undefined): [number, number] | null { @@ -156,12 +216,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] { data: dataIso, ora: (oraIso ?? "").slice(0, 5), avversario: casa ? team2 : team1, + logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "", casa, setNostri: set ? (casa ? set[0] : set[1]) : null, setLoro: set ? (casa ? set[1] : set[0]) : null, parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]), campo: testo(evento.field ?? ""), competizione: testo(evento.project ?? ""), + girone: testo(evento.group ?? ""), + numeroGara: testo(evento.match_number ?? ""), + arbitro: testo(evento.referees ?? ""), + link: evento.link?.trim() ?? "", }); } return partite.sort((a, b) => b.data.localeCompare(a.data)); @@ -192,9 +257,145 @@ export function matchDaPartitaCsi(p: PartitaCsi) { id: p.id, data: p.data, avversario: p.avversario, + logoAvversario: p.logoAvversario, casa: p.casa, setNostri: p.setNostri ?? 0, setLoro: p.setLoro ?? 0, parziali: p.parziali, + campo: p.campo, + girone: p.girone, + numeroGara: p.numeroGara, + arbitro: p.arbitro, + link: p.link, + }; +} + +/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */ +export function parseInfoPartita(html: string): { giornata: string; nota: string } { + const giornataM = /(\d+)\s*a<\/sup>\s*Giornata/.exec(html); + const notaM = /
    ([^<]*)<\/span><\/div>/.exec(html); + return { + giornata: giornataM ? `${giornataM[1]}ª Giornata` : "", + nota: notaM ? testo(notaM[1] ?? "") : "", + }; +} + +/** + * Una squadra dentro `match-players.php`: una `
      ` di `
    • `, in + * ordine titolari → divisore "A DISPOSIZIONE" → panchina → divisore "STAFF" → staff. I + * membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia. + */ +function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null { + const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco); + const squadra = nomeM ? testo(nomeM[1] ?? "") : ""; + if (!squadra) return null; + + const titolari: GiocatoreFormazione[] = []; + const panchina: GiocatoreFormazione[] = []; + const staff: StaffFormazione[] = []; + let sezione: "titolari" | "panchina" | "staff" = "titolari"; + for (const voce of blocco.match(/
    • ([^<]+)<\/a>/.exec(voce); + if (!nomePersonaM) continue; + const nome = testo(nomePersonaM[1] ?? ""); + const ruoloM = /
      ([^<]*)<\/div>/.exec(voce); + const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : ""; + if (sezione === "staff") { + staff.push({ nome, ruolo }); + continue; + } + const numeroM = /shrink-8"[^>]*>(\d+)`; qui si riconosce la nostra tramite `isNostraSquadra()` + * invece di assumere un ordine fisso. + */ +export function parseFormazioni( + html: string, +): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null { + const idx = html.indexOf("SQUADRA OSPITE"); + if (idx === -1) return null; + const primo = parseBloccoSquadraFormazione(html.slice(0, idx)); + const secondo = parseBloccoSquadraFormazione(html.slice(idx)); + if (!primo || !secondo) return null; + const noi = isNostraSquadra(primo.squadra) + ? primo + : isNostraSquadra(secondo.squadra) + ? secondo + : null; + if (!noi) return null; + return { noi, avversario: noi === primo ? secondo : primo }; +} + +/** + * Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di + * riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare) + * riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si + * riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in + * `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe + * vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente. + */ +export function parsePrecedenti(html: string): PrecedentiCsi | null { + const idxCasa = html.indexOf("Squadra casa"); + const idxOspite = html.indexOf("Squadra ospite"); + if (idxCasa === -1 || idxOspite === -1) return null; + const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec( + html.slice(idxCasa, idxOspite), + ); + const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec( + html.slice(idxOspite), + ); + const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : ""; + const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : ""; + const noiECasa = isNostraSquadra(nomeCasa); + if (!noiECasa && !isNostraSquadra(nomeOspite)) return null; + + const totaleM = /Precedenti:<\/span>\s*]*>(\d+)<\/a><\/b>/.exec(html); + const vittorieM = + /
      (\d+)<\/b><\/div>\s*]*>vittorie<\/div>\s*
      (\d+)<\/b><\/div>/.exec(html); + const inCasaM = + /
      (\d+)<\/b><\/div>\s*
      in casa<\/div>\s*
      (\d+)<\/b><\/div>/.exec(html); + const fuoriM = /
      (\d+)<\/b><\/div>\s*
      fuori<\/div>\s*
      (\d+)<\/b><\/div>/.exec( + html, + ); + // Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso) + // segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la + // classe CSS per capire quale percentuale è "nostra". + const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) => + Number(m[1]), + ); + const [probCasa, probOspite] = percentuali; + + // I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara: + // `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`. + const casaIndice = noiECasa ? 1 : 2; + const ospiteIndice = noiECasa ? 2 : 1; + const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0); + + return { + totale: totaleM ? Number(totaleM[1]) : 0, + vinteNoi: numero(vittorieM, casaIndice), + vinteAvversario: numero(vittorieM, ospiteIndice), + casaNoi: numero(inCasaM, casaIndice), + casaAvversario: numero(inCasaM, ospiteIndice), + fuoriNoi: numero(fuoriM, casaIndice), + fuoriAvversario: numero(fuoriM, ospiteIndice), + probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null, + probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null, }; } diff --git a/src/lib/csi-partita.ts b/src/lib/csi-partita.ts new file mode 100644 index 0000000..d0c427b --- /dev/null +++ b/src/lib/csi-partita.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import type { DettaglioPartitaCsi } from "./csi-core"; + +/** + * Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in + * più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il + * dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md. + */ +export function useCsiPartita(matchId: string | undefined) { + return useQuery({ + queryKey: ["csi-partita", matchId], + queryFn: async () => { + const res = await fetch(`/api/public/csi-partita/${matchId}`); + if (!res.ok) throw new Error("csi-partita non disponibile"); + return res.json(); + }, + enabled: !!matchId, + staleTime: 6 * 60 * 60_000, + retry: 1, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f127f35..99c8484 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ProfiloRouteImport } from './routes/profilo' import { Route as ScoutRouteImport } from './routes/scout' import { Route as SquadraRouteImport } from './routes/squadra' import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id' +import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id' import { Route as PartitaIdRouteImport } from './routes/partita.$id' import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio' import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' @@ -26,6 +27,7 @@ import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/pub import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe' import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze' +import { Route as ApiPublicCsiPartitaIdRouteImport } from './routes/api/public/csi-partita.$id' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -77,6 +79,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({ path: '/allenamento/$id', getParentRoute: () => rootRouteImport, } as any) +const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({ + id: '/partita-csi/$id', + path: '/partita-csi/$id', + getParentRoute: () => rootRouteImport, +} as any) const PartitaIdRoute = PartitaIdRouteImport.update({ id: '/partita/$id', path: '/partita/$id', @@ -114,6 +121,11 @@ const ApiPublicSollecitaPresenzeRoute = path: '/api/public/sollecita-presenze', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicCsiPartitaIdRoute = ApiPublicCsiPartitaIdRouteImport.update({ + id: '/api/public/csi-partita/$id', + path: '/api/public/csi-partita/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -126,6 +138,7 @@ export interface FileRoutesByFullPath { '/scout': typeof ScoutRoute '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute + '/partita-csi/$id': typeof PartitaCsiIdRoute '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute @@ -133,6 +146,7 @@ export interface FileRoutesByFullPath { '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute + '/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -145,6 +159,7 @@ export interface FileRoutesByTo { '/scout': typeof ScoutRoute '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute + '/partita-csi/$id': typeof PartitaCsiIdRoute '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute @@ -152,6 +167,7 @@ export interface FileRoutesByTo { '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute + '/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -165,6 +181,7 @@ export interface FileRoutesById { '/scout': typeof ScoutRoute '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute + '/partita-csi/$id': typeof PartitaCsiIdRoute '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute @@ -172,6 +189,7 @@ export interface FileRoutesById { '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute '/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute + '/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -186,6 +204,7 @@ export interface FileRouteTypes { | '/scout' | '/squadra' | '/allenamento/$id' + | '/partita-csi/$id' | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' @@ -193,6 +212,7 @@ export interface FileRouteTypes { | '/api/public/push-config' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' + | '/api/public/csi-partita/$id' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -205,6 +225,7 @@ export interface FileRouteTypes { | '/scout' | '/squadra' | '/allenamento/$id' + | '/partita-csi/$id' | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' @@ -212,6 +233,7 @@ export interface FileRouteTypes { | '/api/public/push-config' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' + | '/api/public/csi-partita/$id' id: | '__root__' | '/' @@ -224,6 +246,7 @@ export interface FileRouteTypes { | '/scout' | '/squadra' | '/allenamento/$id' + | '/partita-csi/$id' | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' @@ -231,6 +254,7 @@ export interface FileRouteTypes { | '/api/public/push-config' | '/api/public/push-subscribe' | '/api/public/sollecita-presenze' + | '/api/public/csi-partita/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -244,6 +268,7 @@ export interface RootRouteChildren { ScoutRoute: typeof ScoutRoute SquadraRoute: typeof SquadraRoute AllenamentoIdRoute: typeof AllenamentoIdRoute + PartitaCsiIdRoute: typeof PartitaCsiIdRoute PartitaIdRoute: typeof PartitaIdRoute ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute @@ -251,6 +276,7 @@ export interface RootRouteChildren { ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute + ApiPublicCsiPartitaIdRoute: typeof ApiPublicCsiPartitaIdRoute } declare module '@tanstack/react-router' { @@ -325,6 +351,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AllenamentoIdRouteImport parentRoute: typeof rootRouteImport } + '/partita-csi/$id': { + id: '/partita-csi/$id' + path: '/partita-csi/$id' + fullPath: '/partita-csi/$id' + preLoaderRoute: typeof PartitaCsiIdRouteImport + parentRoute: typeof rootRouteImport + } '/partita/$id': { id: '/partita/$id' path: '/partita/$id' @@ -374,6 +407,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicSollecitaPresenzeRouteImport parentRoute: typeof rootRouteImport } + '/api/public/csi-partita/$id': { + id: '/api/public/csi-partita/$id' + path: '/api/public/csi-partita/$id' + fullPath: '/api/public/csi-partita/$id' + preLoaderRoute: typeof ApiPublicCsiPartitaIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -388,6 +428,7 @@ const rootRouteChildren: RootRouteChildren = { ScoutRoute: ScoutRoute, SquadraRoute: SquadraRoute, AllenamentoIdRoute: AllenamentoIdRoute, + PartitaCsiIdRoute: PartitaCsiIdRoute, PartitaIdRoute: PartitaIdRoute, ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicCsiRoute: ApiPublicCsiRoute, @@ -395,6 +436,7 @@ const rootRouteChildren: RootRouteChildren = { ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute, ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute, + ApiPublicCsiPartitaIdRoute: ApiPublicCsiPartitaIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/api/public/csi-partita.$id.ts b/src/routes/api/public/csi-partita.$id.ts new file mode 100644 index 0000000..941db5e --- /dev/null +++ b/src/routes/api/public/csi-partita.$id.ts @@ -0,0 +1,63 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + parseFormazioni, + parseInfoPartita, + parsePrecedenti, + urlPartitaFormazioni, + urlPartitaInfo, + urlPartitaPrecedenti, + type DettaglioPartitaCsi, +} from "@/lib/csi-core"; + +const SCADENZA_MS = 6 * 60 * 60 * 1000; + +// Una gara giocata non cambia più: la cache per-partita non ha bisogno di scadere mai +// per i dati storici, ma teniamo la stessa finestra di /api/public/csi per semplicità e +// per non tenere in memoria per sempre partite che nessuno riguarda più. Stessa cache in +// memoria del processo, stesso limite (si perde ai cold start): vedi limite 3 in +// docs/modules/collegamento-csi.md. +const cache = new Map(); + +async function scarica(url: string): Promise { + const res = await fetch(url, { + headers: { "User-Agent": "CrAPP/1.0 (+https://crapvolley.it)" }, + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`CSI ${res.status} su ${url}`); + return res.text(); +} + +async function leggiDettaglioPartita(matchId: string): Promise { + const [main, players, stats] = await Promise.all([ + scarica(urlPartitaInfo(matchId)), + scarica(urlPartitaFormazioni(matchId)), + scarica(urlPartitaPrecedenti(matchId)), + ]); + return { + ...parseInfoPartita(main), + formazioni: parseFormazioni(players), + precedenti: parsePrecedenti(stats), + }; +} + +export const Route = createFileRoute("/api/public/csi-partita/$id")({ + server: { + handlers: { + GET: async ({ params }) => { + const matchId = params.id; + const voce = cache.get(matchId); + if (voce && Date.now() < voce.scadenza) return Response.json(voce.dati); + try { + const dati = await leggiDettaglioPartita(matchId); + cache.set(matchId, { dati, scadenza: Date.now() + SCADENZA_MS }); + return Response.json(dati); + } catch (error) { + console.error("csi-partita", matchId, error); + // Meglio un dato vecchio che nessun dato, come /api/public/csi. + if (voce) return Response.json(voce.dati); + return new Response("CSI non raggiungibile", { status: 503 }); + } + }, + }, + }, +}); diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index bc65017..0b40f59 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -10,6 +10,7 @@ import { useCsi } from "@/lib/csi"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { useEventi } from "@/lib/eventi"; +import { LogoSquadra } from "@/components/crapp/DettaglioCsi"; const TAB_CLASSIFICA = ["classifica", "storico"] as const; type TabClassifica = (typeof TAB_CLASSIFICA)[number]; @@ -110,6 +111,7 @@ function Classifica() { id: m.id, data: m.data, avversario: m.avversario, + logoAvversario: "", casa: m.casa, setNostri: m.setNostri, setLoro: m.setLoro, @@ -170,15 +172,18 @@ function Classifica() { const contenuto = ( <>
      -
      -

      - {m.casa ? "CRAP Volley" : m.avversario} vs{" "} - {m.casa ? m.avversario : "CRAP Volley"} -

      -

      - {formatData(m.data)} · MVP {m.mvp || "da votare"} - {m.scout ? " · scoutata" : ""} -

      +
      + +
      +

      + {m.casa ? "CRAP Volley" : m.avversario} vs{" "} + {m.casa ? m.avversario : "CRAP Volley"} +

      +

      + {formatData(m.data)} · MVP {m.mvp || "da votare"} + {m.scout ? " · scoutata" : ""} +

      +
      ); - return eventoId ? ( + if (eventoId) { + return ( + + {contenuto} + + ); + } + // Senza evento CrAPP collegato (nessuna creazione automatica dal calendario + // CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un + // corrispettivo sul portale da mostrare, quelle CSI sì. + return m.scout ? ( +
      + {contenuto} +
      + ) : ( {contenuto} - ) : ( -
      - {contenuto} -
      ); })}
      diff --git a/src/routes/partita-csi.$id.tsx b/src/routes/partita-csi.$id.tsx new file mode 100644 index 0000000..0409ef9 --- /dev/null +++ b/src/routes/partita-csi.$id.tsx @@ -0,0 +1,118 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { Trophy, Swords } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { PageHeader, Section } from "@/components/crapp/ui-bits"; +import { formatData } from "@/lib/crapp-data"; +import { useCsi } from "@/lib/csi"; +import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi"; + +export const Route = createFileRoute("/partita-csi/$id")({ + head: () => { + const titolo = "Dettaglio partita"; + return { + meta: [ + { title: `${titolo} — CrAPP` }, + { + name: "description", + content: "Dettaglio partita ufficiale CSI del CRAP Volley.", + }, + { property: "og:title", content: `${titolo} — CrAPP` }, + { property: "og:description", content: "Dettaglio partita ufficiale CSI del CRAP Volley." }, + { property: "og:type", content: "website" }, + { name: "twitter:card", content: "summary" }, + ], + }; + }, + component: PartitaCsiDetail, +}); + +/** + * Dettaglio "solo CSI" per le gare senza un evento CrAPP collegato (l'app non crea ancora + * eventi automaticamente dal calendario CSI): niente convocazioni/presenze/MVP/scout, solo + * i dati ufficiali del portale. Per le gare già collegate a un evento vedi `/partita/$id`, + * che mostra le stesse informazioni CSI in più. + */ +function PartitaCsiDetail() { + const { id } = Route.useParams(); + const { data: csi } = useCsi(); + const partita = csi?.partite.find((p) => p.id === id); + + if (!partita) { + return ( +
      +

      Partita non trovata

      +
      + ); + } + + const giocata = partita.setNostri !== null && partita.setLoro !== null; + const vinta = giocata && (partita.setNostri as number) > (partita.setLoro as number); + + return ( + <> + + +
      +
      +
      +
      + {partita.casa ? : } +
      + +
      +

      + {partita.casa ? "In casa" : "Fuori casa"} +

      +

      {partita.avversario}

      +
      + {giocata ? ( + + {partita.setNostri} - {partita.setLoro} + + ) : null} +
      + + {giocata && partita.parziali.length > 0 ? ( +
      + {partita.parziali.map(([noi, loro], i) => ( + + {noi}-{loro} + + ))} +
      + ) : null} + + +
      +
      + +
      + +
      + + ); +} diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index e058097..7935953 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -7,6 +7,7 @@ import { convocatiEvento, useEvento } from "@/lib/eventi"; import { useRosa } from "@/lib/rosa"; import { useCsi } from "@/lib/csi"; import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; +import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi"; import { Pagelle } from "@/components/crapp/Pagelle"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; @@ -72,10 +73,16 @@ function PartitaDetail() { id: scout.id, data: scout.data, avversario: scout.avversario, + logoAvversario: "", casa: scout.casa, setNostri: scout.setNostri, setLoro: scout.setLoro, parziali: scout.parziali, + campo: "", + girone: "", + numeroGara: "", + arbitro: "", + link: "", } : csiMatch ? matchDaPartitaCsi(csiMatch) @@ -105,6 +112,9 @@ function PartitaDetail() { > {casa ? : }
      + {match?.logoAvversario ? ( + + ) : null}

      {casa ? "In casa" : "Fuori casa"} @@ -134,6 +144,15 @@ function PartitaDetail() { Conferme: {presentiVeri}/{convocati.length}

      + {csiMatch ? ( + + ) : null} +
      @@ -171,6 +190,12 @@ function PartitaDetail() { ) : null} + {csiMatch ? ( +
      + +
      + ) : null} +
      diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts index 7eb0265..e41f05c 100644 --- a/test/e2e/percorsi.test.ts +++ b/test/e2e/percorsi.test.ts @@ -70,11 +70,25 @@ try { const html = await pagina("/classifica"); assert.match(titolo(html), /Classifica campionato/); assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio"); + + // Una gara giocata deve avere un dettaglio "solo CSI" raggiungibile, con il suo + // endpoint per-partita servito (formazioni/precedenti): vedi partita-csi.$id.tsx. + const giocata = csi.partite.find((p) => p.setNostri !== null); + if (giocata) { + const htmlPartita = await pagina(`/partita-csi/${giocata.id}`); + assert.match(titolo(htmlPartita), /Dettaglio partita/); + const dettaglio = await fetch(url(`/api/public/csi-partita/${giocata.id}`)); + assert.equal(dettaglio.status, 200, "l'endpoint per-partita risponde"); + } }); // --- 4. Dettaglio di un evento -------------------------------------------- await prova("le pagine di dettaglio reggono un id inesistente", async () => { - for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) { + for (const percorso of [ + "/partita/non-esiste", + "/allenamento/non-esiste", + "/partita-csi/non-esiste", + ]) { const res = await fetch(url(percorso)); assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`); } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4b7bd76..22a1c04 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5,7 +5,12 @@ */ import assert from "node:assert/strict"; import { giocatori } from "@/lib/crapp-data"; -import { isNostraSquadra, partiteGiocate, type DatiCsi } from "@/lib/csi-core"; +import { + isNostraSquadra, + partiteGiocate, + type DatiCsi, + type DettaglioPartitaCsi, +} from "@/lib/csi-core"; import { obiettiviSquadra } from "@/lib/obiettivi"; import { avviaServer, haSupabase, json } from "../helpers/server"; import { prova, riepilogo, salta } from "../helpers/prova"; @@ -119,6 +124,45 @@ try { assert.ok(Date.now() - t0 < 2_000, "risposta immediata"); }); + await provaCsi( + "GET /api/public/csi-partita/$id restituisce formazioni e precedenti", + async () => { + const primaGiocata = partiteGiocate(csi?.partite ?? [])[0]; + assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio"); + const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); + assert.equal(res.status, 200); + const dettaglio = (await json(res)) as DettaglioPartitaCsi; + // formazioni/precedenti possono essere null se il portale non li pubblica per questa + // gara (es. referto non compilato): qui si verifica solo che, quando presenti, siano + // coerenti — non che ci siano sempre, vedi parseFormazioni()/parsePrecedenti(). + if (dettaglio.formazioni) { + assert.ok(isNostraSquadra(dettaglio.formazioni.noi.squadra)); + assert.ok(dettaglio.formazioni.noi.titolari.length > 0, "almeno un titolare"); + } + if (dettaglio.precedenti) { + assert.ok(dettaglio.precedenti.totale >= 0); + } + + const t0 = Date.now(); + const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); + assert.equal(secondo.status, 200); + assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata"); + }, + ); + + await provaCsi( + "GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe", + async () => { + // Un match_id inventato fa rispondere 500 a match-stats.php lato CSI (verificato a + // mano con curl): non è "gara senza formazioni ancora pubblicate" (quello risponde + // 200 con markup vuoto, gestito da parseFormazioni()/parsePrecedenti() → null), è un + // vero errore a monte. La route lo tratta come /api/public/csi tratta un CSI giù: + // 503 pulito, non un crash o un 200 con dati inventati. + const res = await fetch(url("/api/public/csi-partita/999999999")); + assert.equal(res.status, 503); + }, + ); + // --- GET /api/public/push-config ------------------------------------------- await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => { const res = await fetch(url("/api/public/push-config")); diff --git a/test/unit/csi-core.test.ts b/test/unit/csi-core.test.ts index 0b2f0f9..fa4fe8a 100644 --- a/test/unit/csi-core.test.ts +++ b/test/unit/csi-core.test.ts @@ -6,12 +6,19 @@ import assert from "node:assert/strict"; import { CSI_COPPA_PROJECT_ID, isNostraSquadra, + matchDaPartitaCsi, parseClassifica, + parseFormazioni, + parseInfoPartita, + parsePrecedenti, partiteDaEventi, partiteFormatoSospetto, partiteGiocate, urlClassifica, urlPartite, + urlPartitaFormazioni, + urlPartitaInfo, + urlPartitaPrecedenti, } from "@/lib/csi-core"; const htmlClassifica = ` @@ -71,11 +78,17 @@ const eventi = [ id: 1, start: "2025-11-12T22:00:00", team1: "C.R.A.P. Volley", + team1_logo: "images/teams/logos/CRAP_Volley_3359.jpg", team2: "AMCM", + team2_logo: "images/teams/logos/AMCM_1943.JPG", result: "3 - 1", partials: "25 - 23
      23 - 25
      25 - 18
      25 - 22
      ", field: "Sabin Piccolo", project: "PVM - Campionato Open Misto Eccellenza", + group: "Girone B", + match_number: "5/XEB", + referees: "", + link: "https://livescore.csibologna.it/match_details.php?id=1", }, { id: 2, @@ -100,6 +113,14 @@ const casa = partite.find((p) => p.id === "1")!; assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]); assert.deepEqual(casa.parziali[1], [23, 25]); assert.equal(casa.ora, "22:00"); +assert.equal( + casa.logoAvversario, + "images/teams/logos/AMCM_1943.JPG", + "logo dell'avversario, non il nostro", +); +assert.equal(casa.girone, "Girone B"); +assert.equal(casa.numeroGara, "5/XEB"); +assert.equal(casa.link, "https://livescore.csibologna.it/match_details.php?id=1"); const trasferta = partite.find((p) => p.id === "2")!; assert.deepEqual( @@ -112,6 +133,24 @@ 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"); +// --- matchDaPartitaCsi: i campi leggeri (logo, girone, n° gara, arbitro, link) arrivano +// fino alla forma usata nelle liste risultati, non solo nel tipo interno -------------------- +assert.deepEqual(matchDaPartitaCsi(casa), { + id: "1", + data: "2025-11-12", + avversario: "AMCM", + logoAvversario: "images/teams/logos/AMCM_1943.JPG", + casa: true, + setNostri: 3, + setLoro: 1, + parziali: casa.parziali, + campo: "Sabin Piccolo", + girone: "Girone B", + numeroGara: "5/XEB", + arbitro: "", + link: "https://livescore.csibologna.it/match_details.php?id=1", +}); + // --- partiteFormatoSospetto: distingue "nessuna gara ancora" da "formato rotto" ------------- assert.equal( partiteFormatoSospetto(eventi, partite), @@ -140,6 +179,154 @@ assert.equal( "eventi presenti ma con campi rinominati: nessuno riconosciuto, sospetto fondato", ); +// --- parseInfoPartita: giornata e nota libera da match-main.php ----------------------------- +const htmlMatchMain = ` +
      + 2a Giornata +
      + Impianto: Sabin Piccolo +
      Pubblico non ammesso
      +
      +
      `; +assert.deepEqual(parseInfoPartita(htmlMatchMain), { + giornata: "2ª Giornata", + nota: "Pubblico non ammesso", +}); +assert.deepEqual( + parseInfoPartita("

      pagina cambiata

      "), + { giornata: "", nota: "" }, + "portale cambiato: nessun crash, campi vuoti", +); + +// --- parseFormazioni: titolari/panchina/staff di entrambe le squadre, noi riconosciuti +// tramite isNostraSquadra() e non un ordine fisso ------------------------------------------ +const htmlFormazioni = ` + +
      + C.R.A.P. Volley + +
      + +
      + AMCM + +
      `; +const formazioni = parseFormazioni(htmlFormazioni)!; +assert.equal(formazioni.noi.squadra, "C.R.A.P. Volley"); +assert.equal( + formazioni.avversario.squadra, + "AMCM", + "riconosciuta come avversario anche da seconda posizione", +); +assert.deepEqual(formazioni.noi.titolari, [ + { numero: "73", nome: "Bologna Mattias", ruolo: "" }, + { numero: "11", nome: "Chilese Silvia", ruolo: "Libero" }, +]); +assert.deepEqual(formazioni.noi.panchina, [ + { numero: "14", nome: "Di Castelnuovo Carlo", ruolo: "" }, +]); +assert.deepEqual(formazioni.noi.staff, [{ nome: "Ricci Iacopo", ruolo: "Allenatore" }]); +assert.equal(formazioni.avversario.titolari.length, 1); +assert.deepEqual(parseFormazioni("

      pagina cambiata

      "), null, "portale cambiato: nessun crash"); +assert.deepEqual( + parseFormazioni(htmlFormazioni.replace("C.R.A.P. Volley", "Un'altra squadra")), + null, + "nessuna delle due è la nostra squadra: formato non riconosciuto", +); + +// --- parsePrecedenti: storico scontri diretti e probabilità di vittoria da match-stats.php -- +const htmlPrecedenti = ` +
      Squadra casa + C.R.A.P. Volley +
      +
      Squadra ospite + AMCM +
      +
      Precedenti: 5
      +
      +
      3
      +
      vittorie
      +
      2
      +
      +
      +
      1
      +
      in casa
      +
      1
      +
      +
      +
      2
      +
      fuori
      +
      1
      +
      +
      56.97%
      +
      43.03%
      `; +assert.deepEqual(parsePrecedenti(htmlPrecedenti), { + totale: 5, + vinteNoi: 3, + vinteAvversario: 2, + casaNoi: 1, + casaAvversario: 1, + fuoriNoi: 2, + fuoriAvversario: 1, + probabilitaNoi: 56.97, + probabilitaAvversario: 43.03, +}); +// Se siamo la squadra ospite invece che casa, i numeri vanno scambiati di conseguenza. +const precedentiOspiti = parsePrecedenti( + htmlPrecedenti + .replace("C.R.A.P. Volley", "__TMP__") + .replace("AMCM", "C.R.A.P. Volley") + .replace("__TMP__", "AMCM"), +)!; +assert.deepEqual( + [precedentiOspiti.vinteNoi, precedentiOspiti.vinteAvversario, precedentiOspiti.probabilitaNoi], + [2, 3, 43.03], + "noi/avversario scambiati quando siamo la squadra ospite", +); +assert.equal(parsePrecedenti("

      pagina cambiata

      "), null, "portale cambiato: nessun crash"); + +// --- URL dei nuovi endpoint per-partita ------------------------------------------------------ +assert.equal( + urlPartitaInfo("33694"), + "https://livescore.csibologna.it/components/match-main.php?match_id=33694", +); +assert.equal( + urlPartitaFormazioni("33694"), + "https://livescore.csibologna.it/components/match-players.php?match_id=33694", +); +assert.equal( + urlPartitaPrecedenti("33694"), + "https://livescore.csibologna.it/components/match-stats.php?match_id=33694", +); + if (process.env["CSI_LIVE"]) { const [html, htmlCoppa, json] = await Promise.all([ fetch(urlClassifica()).then((r) => r.text()), @@ -156,8 +343,29 @@ if (process.env["CSI_LIVE"]) { "la nostra squadra è nel girone di Coppa", ); const livePartite = partiteDaEventi(json); - assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0); - console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`); + const liveGiocate = partiteGiocate(livePartite); + assert.ok(livePartite.length > 0 && liveGiocate.length > 0); + assert.ok( + liveGiocate.every((p) => p.link.startsWith("https://")), + "ogni partita ha un referto", + ); + + const primaGiocata = liveGiocate[0]!; + const [liveMain, livePlayers, liveStats] = await Promise.all([ + fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()), + fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()), + fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()), + ]); + const liveFormazioni = parseFormazioni(livePlayers); + assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata"); + assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra)); + const livePrecedenti = parsePrecedenti(liveStats); + assert.ok(livePrecedenti, "precedenti riconosciuti"); + console.log( + `live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` + + `${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` + + `formazione ${liveFormazioni!.noi.titolari.length} titolari`, + ); } console.log("csi-core: ok");