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
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.
- **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
+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
`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`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`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
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)
↓ 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.
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`.
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
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
+37
View File
@@ -21,6 +21,43 @@ export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
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) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
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 { useScoutMatches } from "@/lib/scout-store";
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 { useEventi } from "@/lib/eventi";
import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
@@ -127,6 +133,8 @@ function Classifica() {
const classifica = useMemo(() => csi?.classifica ?? [], [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 eventoIdPerData = useMemo(
@@ -200,119 +208,127 @@ function Classifica() {
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const cliccabile = Boolean(eventoId) || !m.scout;
const casa = {
nome: m.casa ? NOME_NOI : m.avversario,
nostro: m.casa,
};
const trasferta = {
nome: m.casa ? m.avversario : NOME_NOI,
nostro: !m.casa,
};
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
const setCasa = m.casa ? m.setNostri : m.setLoro;
const setOspite = m.casa ? m.setLoro : m.setNostri;
const contenuto = (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={casa.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={trasferta.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
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 (
<div className="space-y-5">
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
<section key={stagione} className="space-y-3">
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
{match.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const cliccabile = Boolean(eventoId) || !m.scout;
const casa = {
nome: m.casa ? NOME_NOI : m.avversario,
nostro: m.casa,
};
const trasferta = {
nome: m.casa ? m.avversario : NOME_NOI,
nostro: !m.casa,
};
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
const setCasa = m.casa ? m.setNostri : m.setLoro;
const setOspite = m.casa ? m.setLoro : m.setNostri;
const contenuto = (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={casa.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={trasferta.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{puntiCasa}-{puntiOspite}
{setCasa}-{setOspite}
</span>
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
</div>
</>
);
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
if (eventoId) {
return (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
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>
);
}
// 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>
);
})}
{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
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{puntiCasa}-{puntiOspite}
</span>
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
</div>
</>
);
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
if (eventoId) {
return (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
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>
);
}
// 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>
),
[tuttiMatch, eventoIdPerData],
@@ -322,7 +338,13 @@ function Classifica() {
<>
<PageHeader
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
+33
View File
@@ -10,6 +10,9 @@ import {
parseClassifica,
parseFormazioni,
parseInfoPartita,
raggruppaPerStagione,
stagioneDaPartite,
stagioneDi,
parsePrecedenti,
partiteDaEventi,
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");