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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user