Mostra la stagione CSI nell'header di Campionato e divide lo storico per stagione

Il portale CSI non espone il nome della stagione: stagioneDi() la ricava dalla data
della gara (l'anno cambia ad agosto), stagioneDaPartite() dà quella del campionato e
raggruppaPerStagione() divide lo storico partite. Coperte da test in csi-core.test.ts.

Documentato che oggi Campionato mostra 2025/26 mentre Squadra e Calendario mostrano
2026/27: il CSI non ha ancora creato le pagine della nuova stagione.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-24 14:01:55 +02:00
1 parent 33ed2398ab
commit ae5d790011
5 files changed
+218 -111

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ all'indietro.
- **Notifiche in-app** — tolta la campanella in alto a destra: il numero delle notifiche è ora - **Notifiche in-app** — tolta la campanella in alto a destra: il numero delle notifiche è ora
un pallino rosso sull'avatar del profilo. Toccando il pallino si apre l'elenco (leggi ed un pallino rosso sull'avatar del profilo. Toccando il pallino si apre l'elenco (leggi ed
elimina), toccando l'avatar si va al profilo come prima. elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
## [0.9.2] - 2026-09-10 ## [0.9.2] - 2026-09-10
+14 -1
View File
@@ -43,6 +43,15 @@ un `project_id`), nessun codice le chiama direttamente.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in `project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`. `src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27.
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359` Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link (dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta — `league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
@@ -155,7 +164,8 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓ ↓
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone /classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo, sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili) chevron di dettaglio sulle gare cliccabili, gare divise
per stagione; stagione anche nel sottotitolo dell'header)
CSI (portale, 3 endpoint) CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore ↓ fetch server-side on-demand, cache per-partita 6 ore
@@ -239,6 +249,9 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing. Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi 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`. "Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra 3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di 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 `/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
+37
View File
@@ -21,6 +21,43 @@ export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B"; export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley"; export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
/**
* Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non
* espone il nome della stagione (né nella classifica né nel calendario della squadra):
* si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di
* settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono.
*/
export function stagioneDi(dataIso: string): string {
const anno = Number(dataIso.slice(0, 4));
const mese = Number(dataIso.slice(5, 7));
const inizio = mese >= 8 ? anno : anno - 1;
return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`;
}
/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */
export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null {
const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d));
if (date.length === 0) return null;
return stagioneDi(date.reduce((a, b) => (a < b ? a : b)));
}
/**
* Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in
* locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato.
*/
export function raggruppaPerStagione<T extends { data: string }>(
match: readonly T[],
): Array<{ stagione: string; match: T[] }> {
const gruppi: Array<{ stagione: string; match: T[] }> = [];
for (const m of match) {
const stagione = stagioneDi(m.data);
const gruppo = gruppi.find((g) => g.stagione === stagione);
if (gruppo) gruppo.match.push(m);
else gruppi.push({ stagione, match: [m] });
}
return gruppi;
}
export const urlClassifica = (projectId = CSI_PROJECT_ID) => export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`; `${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
export const urlPartite = (teamId = CSI_TEAM_ID) => export const urlPartite = (teamId = CSI_TEAM_ID) =>
+132 -110
View File
@@ -7,7 +7,13 @@ import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store"; import { useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import {
isNostraSquadra,
matchDaPartitaCsi,
partiteGiocate,
raggruppaPerStagione,
stagioneDaPartite,
} from "@/lib/csi-core";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi"; import { useEventi } from "@/lib/eventi";
import { LogoSquadra } from "@/components/crapp/DettaglioCsi"; import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
@@ -127,6 +133,8 @@ function Classifica() {
const classifica = useMemo(() => csi?.classifica ?? [], [csi]); const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]); const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]);
// Tutte le gare, non solo quelle giocate: a inizio stagione il calendario c'è già.
const stagioneCsi = useMemo(() => stagioneDaPartite(csi?.partite ?? []), [csi]);
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]); const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
const eventoIdPerData = useMemo( const eventoIdPerData = useMemo(
@@ -200,119 +208,127 @@ function Classifica() {
Nessuna partita disponibile. Nessuna partita disponibile.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-5">
{tuttiMatch.map((m) => { {raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
const vinta = m.setNostri > m.setLoro; <section key={stagione} className="space-y-3">
const eventoId = eventoIdPerData.get(m.data); <h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
const cliccabile = Boolean(eventoId) || !m.scout; {match.map((m) => {
const casa = { const vinta = m.setNostri > m.setLoro;
nome: m.casa ? NOME_NOI : m.avversario, const eventoId = eventoIdPerData.get(m.data);
nostro: m.casa, const cliccabile = Boolean(eventoId) || !m.scout;
}; const casa = {
const trasferta = { nome: m.casa ? NOME_NOI : m.avversario,
nome: m.casa ? m.avversario : NOME_NOI, nostro: m.casa,
nostro: !m.casa, };
}; const trasferta = {
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP. nome: m.casa ? m.avversario : NOME_NOI,
const setCasa = m.casa ? m.setNostri : m.setLoro; nostro: !m.casa,
const setOspite = m.casa ? m.setLoro : m.setNostri; };
const contenuto = ( // Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
<> const setCasa = m.casa ? m.setNostri : m.setLoro;
<div className="flex items-center gap-2"> const setOspite = m.casa ? m.setLoro : m.setNostri;
<div className="min-w-0 flex-1 space-y-1.5"> const contenuto = (
<div className="flex min-w-0 items-center gap-2"> <>
<LogoPartita <div className="flex items-center gap-2">
nostro={casa.nostro} <div className="min-w-0 flex-1 space-y-1.5">
logoAvversario={m.logoAvversario} <div className="flex min-w-0 items-center gap-2">
avversario={m.avversario} <LogoPartita
/> nostro={casa.nostro}
<p className="truncate text-sm font-bold">{casa.nome}</p> logoAvversario={m.logoAvversario}
</div> avversario={m.avversario}
<div className="flex min-w-0 items-center gap-2"> />
<LogoPartita <p className="truncate text-sm font-bold">{casa.nome}</p>
nostro={trasferta.nostro} </div>
logoAvversario={m.logoAvversario} <div className="flex min-w-0 items-center gap-2">
avversario={m.avversario} <LogoPartita
/> nostro={trasferta.nostro}
<p className="truncate text-sm font-bold">{trasferta.nome}</p> logoAvversario={m.logoAvversario}
</div> avversario={m.avversario}
<p className="text-xs text-muted-foreground"> />
{formatData(m.data)} · MVP {m.mvp || "da votare"} <p className="truncate text-sm font-bold">{trasferta.nome}</p>
{m.scout ? " · scoutata" : ""} </div>
</p> <p className="text-xs text-muted-foreground">
</div> {formatData(m.data)} · MVP {m.mvp || "da votare"}
<span {m.scout ? " · scoutata" : ""}
className={cn( </p>
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl", </div>
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{setCasa}-{setOspite}
</span>
{cliccabile ? (
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" aria-hidden />
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => {
const puntiCasa = m.casa ? p[0] : p[1];
const puntiOspite = m.casa ? p[1] : p[0];
const setVintoDaNoi = p[0] > p[1];
return (
<span <span
key={i}
className={cn( className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums", "shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground", vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)} )}
> >
{puntiCasa}-{puntiOspite} {setCasa}-{setOspite}
</span> </span>
); {cliccabile ? (
})} <ChevronRight
{eventoId && !m.mvp ? ( className="h-5 w-5 shrink-0 text-muted-foreground"
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent"> aria-hidden
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden /> />
</span> ) : null}
) : null} </div>
</div> <div className="mt-3 flex flex-wrap items-center gap-1.5">
</> {m.parziali.map((p, i) => {
); const puntiCasa = m.casa ? p[0] : p[1];
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`; const puntiOspite = m.casa ? p[1] : p[0];
if (eventoId) { const setVintoDaNoi = p[0] > p[1];
return ( return (
<Link <span
key={m.id} key={i}
to="/partita/$id" className={cn(
params={{ id: eventoId }} "rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
aria-label={aria} setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30" )}
> >
{contenuto} {puntiCasa}-{puntiOspite}
</Link> </span>
); );
} })}
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario {eventoId && !m.mvp ? (
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un <span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
// corrispettivo sul portale da mostrare, quelle CSI sì. Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
return m.scout ? ( </span>
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card"> ) : null}
{contenuto} </div>
</article> </>
) : ( );
<Link const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
key={m.id} if (eventoId) {
to="/partita-csi/$id" return (
params={{ id: m.id }} <Link
aria-label={aria} key={m.id}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30" to="/partita/$id"
> params={{ id: eventoId }}
{contenuto} aria-label={aria}
</Link> className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
); >
})} {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-csi/$id"
params={{ id: m.id }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
})}
</section>
))}
</div> </div>
), ),
[tuttiMatch, eventoIdPerData], [tuttiMatch, eventoIdPerData],
@@ -322,7 +338,13 @@ function Classifica() {
<> <>
<PageHeader <PageHeader
titolo="Campionato" titolo="Campionato"
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"} sottotitolo={
csi
? [csi.girone, "CSI Bologna", stagioneCsi && `Stagione ${stagioneCsi}`]
.filter(Boolean)
.join(" · ")
: "CSI Bologna"
}
/> />
<BarraSottosezioni <BarraSottosezioni
+33
View File
@@ -10,6 +10,9 @@ import {
parseClassifica, parseClassifica,
parseFormazioni, parseFormazioni,
parseInfoPartita, parseInfoPartita,
raggruppaPerStagione,
stagioneDaPartite,
stagioneDi,
parsePrecedenti, parsePrecedenti,
partiteDaEventi, partiteDaEventi,
partiteFormatoSospetto, partiteFormatoSospetto,
@@ -368,4 +371,34 @@ if (process.env["CSI_LIVE"]) {
); );
} }
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
assert.equal(stagioneDi("2025-11-12"), "2025/26");
assert.equal(stagioneDi("2026-05-20"), "2025/26");
assert.equal(stagioneDi("2026-07-31"), "2025/26");
assert.equal(stagioneDi("2026-08-01"), "2026/27");
assert.equal(stagioneDi("2099-10-01"), "2099/00");
assert.equal(stagioneDaPartite([]), null);
assert.equal(
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
"2025/26",
);
assert.deepEqual(
raggruppaPerStagione([
{ id: "a", data: "2026-04-10" },
{ id: "b", data: "2025-10-01" },
{ id: "c", data: "2025-05-02" },
]),
[
{
stagione: "2025/26",
match: [
{ id: "a", data: "2026-04-10" },
{ id: "b", data: "2025-10-01" },
],
},
{ stagione: "2024/25", match: [{ id: "c", data: "2025-05-02" }] },
],
);
console.log("csi-core: ok"); console.log("csi-core: ok");