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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:07:03 +02:00
co-authored by Claude Sonnet 5
parent c628997934
commit 5569d08b59
12 changed files with 1207 additions and 38 deletions
+283
View File
@@ -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>
);
}
+201
View File
@@ -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,
};
}
+21
View File
@@ -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,
});
}
+42
View File
@@ -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)
+63
View File
@@ -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
View File
@@ -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>
+118
View File
@@ -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>
</>
);
}
+25
View File
@@ -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} />