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:
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => 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 (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{voci ? <span>{voci}</span> : null}
|
||||
{link ? (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 font-semibold text-accent"
|
||||
>
|
||||
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{squadra.squadra}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{squadra.titolari.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
||||
{g.numero}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{squadra.panchina.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
|
||||
A disposizione
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{squadra.panchina.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{squadra.staff.length > 0 ? (
|
||||
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
||||
{squadra.staff.map((s, i) => (
|
||||
<div key={i}>
|
||||
{s.nome} · {s.ruolo}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarraPrecedenti({
|
||||
label,
|
||||
noi,
|
||||
avversario,
|
||||
}: {
|
||||
label: string;
|
||||
noi: number;
|
||||
avversario: number;
|
||||
}) {
|
||||
const totale = noi + avversario || 1;
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="font-bold text-foreground">{noi}</span>
|
||||
<span>{label}</span>
|
||||
<span className="font-bold text-foreground">{avversario}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarraProbabilita({
|
||||
avversario,
|
||||
noi,
|
||||
avversarioPct,
|
||||
}: {
|
||||
avversario: string;
|
||||
noi: number;
|
||||
avversarioPct: number;
|
||||
}) {
|
||||
const favoritaNoi = noi >= avversarioPct;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Probabilità di vittoria (calcolo CSI):{" "}
|
||||
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||
</p>
|
||||
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
|
||||
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
|
||||
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
|
||||
{avversario}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-7 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
||||
style={{ width: `${noi}%` }}
|
||||
>
|
||||
{noi.toFixed(0)}%
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
||||
style={{ width: `${avversarioPct}%` }}
|
||||
>
|
||||
{avversarioPct.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BloccoPrecedenti({
|
||||
precedenti,
|
||||
avversario,
|
||||
}: {
|
||||
precedenti: PrecedentiCsi;
|
||||
avversario: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TestataSquadre avversario={avversario} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{precedenti.totale > 0
|
||||
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||
</p>
|
||||
{precedenti.totale > 0 ? (
|
||||
<>
|
||||
<BarraPrecedenti
|
||||
label="vittorie"
|
||||
noi={precedenti.vinteNoi}
|
||||
avversario={precedenti.vinteAvversario}
|
||||
/>
|
||||
<BarraPrecedenti
|
||||
label="in casa"
|
||||
noi={precedenti.casaNoi}
|
||||
avversario={precedenti.casaAvversario}
|
||||
/>
|
||||
<BarraPrecedenti
|
||||
label="fuori"
|
||||
noi={precedenti.fuoriNoi}
|
||||
avversario={precedenti.fuoriAvversario}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
|
||||
<BarraProbabilita
|
||||
avversario={avversario}
|
||||
noi={precedenti.probabilitaNoi}
|
||||
avversarioPct={precedenti.probabilitaAvversario}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||
}
|
||||
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.giornata || data.nota ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{data.formazioni ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ListaFormazione squadra={data.formazioni.noi} />
|
||||
<ListaFormazione squadra={data.formazioni.avversario} />
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
{data.precedenti ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||
</div>
|
||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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*<sup>a<\/sup>\s*Giornata/.exec(html);
|
||||
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
|
||||
return {
|
||||
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
|
||||
nota: notaM ? testo(notaM[1] ?? "") : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, 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(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
|
||||
if (/A DISPOSIZIONE/.test(voce)) {
|
||||
sezione = "panchina";
|
||||
continue;
|
||||
}
|
||||
if (/STAFF/.test(voce)) {
|
||||
sezione = "staff";
|
||||
continue;
|
||||
}
|
||||
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
|
||||
if (!nomePersonaM) continue;
|
||||
const nome = testo(nomePersonaM[1] ?? "");
|
||||
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
|
||||
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
|
||||
if (sezione === "staff") {
|
||||
staff.push({ nome, ruolo });
|
||||
continue;
|
||||
}
|
||||
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
|
||||
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
|
||||
(sezione === "titolari" ? titolari : panchina).push(giocatore);
|
||||
}
|
||||
return { squadra, titolari, panchina, staff };
|
||||
}
|
||||
|
||||
/**
|
||||
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
|
||||
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
|
||||
* `<!-- SQUADRA OSPITE -->`; 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*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
|
||||
const vittorieM =
|
||||
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||
const inCasaM =
|
||||
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<DettaglioPartitaCsi>({
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, { dati: DettaglioPartitaCsi; scadenza: number }>();
|
||||
|
||||
async function scarica(url: string): Promise<string> {
|
||||
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<DettaglioPartitaCsi> {
|
||||
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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+36
-16
@@ -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 = (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||
{m.casa ? m.avversario : "CRAP Volley"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoSquadra src={m.logoAvversario} alt={m.avversario} className="h-8 w-8" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||
{m.casa ? m.avversario : "CRAP Volley"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -211,19 +216,34 @@ function Classifica() {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return eventoId ? (
|
||||
if (eventoId) {
|
||||
return (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// 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 ? (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
) : (
|
||||
<Link
|
||||
key={m.id}
|
||||
to="/partita/$id"
|
||||
params={{ id: eventoId }}
|
||||
to="/partita-csi/$id"
|
||||
params={{ id: m.id }}
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||
>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||
{contenuto}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const giocata = partita.setNostri !== null && partita.setLoro !== null;
|
||||
const vinta = giocata && (partita.setNostri as number) > (partita.setLoro as number);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Partita" sottotitolo={formatData(partita.data)} />
|
||||
|
||||
<Section titolo={partita.competizione || "Campionato"}>
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-12 w-12 shrink-0 place-items-center rounded-2xl",
|
||||
partita.casa ? "bg-primary/15 text-primary" : "bg-accent/15 text-accent",
|
||||
)}
|
||||
>
|
||||
{partita.casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
|
||||
</div>
|
||||
<LogoSquadra
|
||||
src={partita.logoAvversario}
|
||||
alt={partita.avversario}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{partita.casa ? "In casa" : "Fuori casa"}
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</p>
|
||||
</div>
|
||||
{giocata ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1 font-display text-lg",
|
||||
vinta
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-destructive text-destructive-foreground",
|
||||
)}
|
||||
>
|
||||
{partita.setNostri} - {partita.setLoro}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{giocata && partita.parziali.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{partita.parziali.map(([noi, loro], i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{noi}-{loro}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MetaPartitaCsi
|
||||
girone={partita.girone}
|
||||
numeroGara={partita.numeroGara}
|
||||
arbitro={partita.arbitro}
|
||||
link={partita.link}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Formazioni e scontri diretti">
|
||||
<DettaglioCsiEsteso matchId={partita.id} avversario={partita.avversario} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
|
||||
</div>
|
||||
{match?.logoAvversario ? (
|
||||
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{casa ? "In casa" : "Fuori casa"}
|
||||
@@ -134,6 +144,15 @@ function PartitaDetail() {
|
||||
Conferme: {presentiVeri}/{convocati.length}
|
||||
</div>
|
||||
|
||||
{csiMatch ? (
|
||||
<MetaPartitaCsi
|
||||
girone={csiMatch.girone}
|
||||
numeroGara={csiMatch.numeroGara}
|
||||
arbitro={csiMatch.arbitro}
|
||||
link={csiMatch.link}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TurnoPalloni eventoId={evento.id} />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -171,6 +190,12 @@ function PartitaDetail() {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{csiMatch ? (
|
||||
<Section titolo="Formazioni e scontri diretti">
|
||||
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section titolo="MVP eletto dalla squadra">
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<VotazioneMvp evento={evento} />
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+210
-2
@@ -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</br>23 - 25</br>25 - 18</br>25 - 22</br>",
|
||||
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 = `
|
||||
<div>
|
||||
<small><small class="d-block">2<sup>a</sup> Giornata</small></small>
|
||||
<div class="pt-3 border-top text-center">
|
||||
Impianto: <a href="field_details.php?id=400">Sabin Piccolo</a>
|
||||
<div class="text-start"><span>Pubblico non ammesso</span></div>
|
||||
</div>
|
||||
</div>`;
|
||||
assert.deepEqual(parseInfoPartita(htmlMatchMain), {
|
||||
giornata: "2ª Giornata",
|
||||
nota: "Pubblico non ammesso",
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseInfoPartita("<p>pagina cambiata</p>"),
|
||||
{ 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 = `
|
||||
<!-- SQUADRA CASA -->
|
||||
<div class="col-12 col-md-6 mt-4">
|
||||
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
|
||||
<ul class="list-group mt-2">
|
||||
<li class="list-group-item">
|
||||
<span class="fa-layers-text" data-fa-transform="shrink-8">73</span>
|
||||
<strong><a href="person_details.php?id=1">Bologna Mattias</a></strong>
|
||||
<div class="small"></div>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<span class="fa-layers-text" data-fa-transform="shrink-8">11</span>
|
||||
<strong><a href="person_details.php?id=2">Chilese Silvia</a></strong>
|
||||
<div class="small">Libero</div>
|
||||
</li>
|
||||
<li class="list-group-item bg-light"><strong>A DISPOSIZIONE</strong></li>
|
||||
<li class="list-group-item">
|
||||
<span class="fa-layers-text" data-fa-transform="shrink-8">14</span>
|
||||
<strong><a href="person_details.php?id=3">Di Castelnuovo Carlo</a></strong>
|
||||
<div class="small"></div>
|
||||
</li>
|
||||
<li class="list-group-item bg-light"><strong>STAFF</strong></li>
|
||||
<li class="list-group-item">
|
||||
<strong><a href="person_details.php?id=4">Ricci Iacopo</a></strong>
|
||||
<div class="small">Allenatore</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- SQUADRA OSPITE -->
|
||||
<div class="col-12 col-md-6 mt-4">
|
||||
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
|
||||
<ul class="list-group mt-2">
|
||||
<li class="list-group-item">
|
||||
<span class="fa-layers-text" data-fa-transform="shrink-8">7</span>
|
||||
<strong><a href="person_details.php?id=5">Rossi Anna</a></strong>
|
||||
<div class="small"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>`;
|
||||
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("<p>pagina cambiata</p>"), 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 = `
|
||||
<div class="col-12 col-md-6 my-3">Squadra casa
|
||||
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 my-3">Squadra ospite
|
||||
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
|
||||
</div>
|
||||
<div><span class="text-muted">Precedenti:</span> <b><a href="#">5</a></b></div>
|
||||
<div class="d-flex justify-content-between pt-2 px-5">
|
||||
<div><b>3</b></div>
|
||||
<div class="">vittorie</div>
|
||||
<div><b>2</b></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted px-5">
|
||||
<div><b>1</b></div>
|
||||
<div>in casa</div>
|
||||
<div><b>1</b></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted px-5">
|
||||
<div><b>2</b></div>
|
||||
<div>fuori</div>
|
||||
<div><b>1</b></div>
|
||||
</div>
|
||||
<div class="progress-bar bg-success" style="width: 56.97%;">56.97%</div>
|
||||
<div class="progress-bar bg-danger" style="width: 43.03%;">43.03%</div>`;
|
||||
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("<p>pagina cambiata</p>"), 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");
|
||||
|
||||
Reference in New Issue
Block a user