diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5374edd..e596ce9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,13 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum ## Versione attuale +### Collegamento CSI + +- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna + (stagione 2025/26, Campionato Open Misto Eccellenza, Girone B). +- La pagina Campionato non usa più dati dimostrativi. +- Dettagli e limiti in `docs/modules/collegamento-csi.md`. + ### Infrastruttura - Migrazione completa da Lovable a sviluppo locale. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4fb2502..30846d9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -31,9 +31,9 @@ ## Versione 2.0 -- [ ] Collegamento CSI -- [ ] Classifica automatica -- [ ] Risultati campionato +- [x] Collegamento CSI (stagione 2025/26) +- [x] Classifica automatica +- [x] Risultati campionato - [ ] Calendario ufficiale --- diff --git a/docs/TODO.md b/docs/TODO.md index 011b5ee..3e98a9d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -17,7 +17,8 @@ - AI Allenamenti. - Dashboard amministratore. -- Collegamento CSI. +- Collegamento CSI: aggiornare `project_id` e `team_id` per la stagione 2026/27 + (base implementata, vedi `docs/modules/collegamento-csi.md`). --- diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md new file mode 100644 index 0000000..7e3bf65 --- /dev/null +++ b/docs/modules/collegamento-csi.md @@ -0,0 +1,96 @@ +# Modulo — Collegamento CSI + +**Stato:** implementato (stagione 2025/26) +**Route interessata:** `/classifica` + +--- + +## Obiettivo + +Mostrare nell'app la classifica e i risultati **ufficiali** del campionato CSI, al posto +dei dati dimostrativi hardcoded in `crapp-data.ts`. Nessun inserimento manuale da parte +degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere. + +--- + +## Sorgente dati + +Portale **Livescore CSI Bologna** (`https://livescore.csibologna.it`). + +Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi endpoint +che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza +API key, ma **non offrono alcuna garanzia di stabilità**. + +| Endpoint | Formato | Uso | +|---|---|---| +| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi | +| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali | + +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`, `team-roster.php`, `team-results.php`. + +### Identificativi (stagione 2025/26) + +| Cosa | Valore | +|---|---| +| Campionato | PVM - Campionato Open Misto Eccellenza | +| `project_id` | `767` | +| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | +| `team_id` | `3359` | +| Girone | B | + +Gli identificativi sono costanti in `src/lib/csi-core.ts`. + +--- + +## Implementazione + +``` +CSI (portale) + ↓ fetch server-side, cache 6 ore +/api/public/csi → src/routes/api/public/csi.ts + ↓ JSON { classifica, partite, girone, aggiornato } +useCsi() → src/lib/csi.ts (React Query, staleTime 6h) + ↓ +/classifica → src/routes/classifica.tsx +``` + +- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe), + `partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`. +- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di + 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno). +- **`src/lib/csi.ts`** — hook client, una lettura per sessione. +- **`src/lib/csi-core.test.ts`** — check del parsing: `bun src/lib/csi-core.test.ts`. + Con `CSI_LIVE=1` verifica anche gli endpoint reali. + +### 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. +- **Fallback**: se il CSI non risponde, `/classifica` mostra i dati esistenti come prima. +- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo. + +--- + +## Limiti noti + +1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il + parsing restituisce un array vuoto e l'app ricade sui dati demo — non si rompe, ma i dati + sono vecchi. Il check con `CSI_LIVE=1` serve a scoprirlo. +2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da + `team_details.php?team_id=3359`, che elenca i campionati della squadra. 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`). +4. **I risultati includono anche la Coppa**, non solo il girone di campionato. + +--- + +## Evoluzioni possibili + +- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili). +- Creazione automatica degli eventi partita da calendario CSI. +- Confronto tra i parziali ufficiali e quelli dello Scout Live. diff --git a/src/lib/csi-core.test.ts b/src/lib/csi-core.test.ts new file mode 100644 index 0000000..46b7dec --- /dev/null +++ b/src/lib/csi-core.test.ts @@ -0,0 +1,113 @@ +/** + * Check del parsing CSI: `bun src/lib/csi-core.test.ts`. + * Con CSI_LIVE=1 verifica anche gli endpoint reali del portale. + */ +import assert from "node:assert/strict"; +import { + isNostraSquadra, + parseClassifica, + partiteDaEventi, + partiteGiocate, + urlClassifica, + urlPartite, +} from "./csi-core"; + +const htmlClassifica = ` +
| Pos | Squadra | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Altra Squadra Girone A | +40 | 16 | 13 | 3 | 2 | 1 | 42 | 18 | 24 |
| Pos | Squadra | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Paolo Poggi Volley & C. | +40 | 16 | 13 | 3 | 2 | 1 | 43 | 17 | 26 | ||||||
| 4 | +
+ C.R.A.P. Volley |
+ 30 | 16 | 10 | 6 | 3 | 3 | 36 | 28 | 8 | +1427 | 1329 | 98 | 0.56 | 1.07 | ++ |
pagina cambiata
"), [], "portale cambiato: nessun crash"); + +assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley ")); +assert.ok(!isNostraSquadra("CRAP Volley B")); + +const eventi = [ + { + id: 1, + start: "2025-11-12T22:00:00", + team1: "C.R.A.P. Volley", + team2: "AMCM", + result: "3 - 1", + partials: "25 - 2323 - 2525 - 1825 - 22", + field: "Sabin Piccolo", + project: "PVM - Campionato Open Misto Eccellenza", + }, + { + id: 2, + start: "2025-11-19T22:00:00", + team1: "Gnu Tim SdC", + team2: "C.R.A.P. Volley", + result: "3 - 2", + partials: "25 - 2118 - 25", + field: "BVI", + project: "PVM - Campionato Open Misto Eccellenza", + }, + { id: 3, start: "2026-09-10T21:00:00", team1: "C.R.A.P. Volley", team2: "Esselle", result: "" }, + { id: 4, start: "2025-10-01T21:00:00", team1: "Esselle", team2: "AMCM", result: "3 - 0" }, +]; + +const partite = partiteDaEventi(eventi); +assert.equal(partite.length, 3, "scarta le gare di altre squadre"); +assert.equal(partite[0]!.data, "2026-09-10", "ordinate dalla più recente"); +assert.equal(partite[0]!.setNostri, null, "gara futura senza risultato"); + +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"); + +const trasferta = partite.find((p) => p.id === "2")!; +assert.deepEqual( + [trasferta.casa, trasferta.avversario, trasferta.setNostri, trasferta.setLoro], + [false, "Gnu Tim SdC", 2, 3], + "in trasferta il punteggio va ribaltato", +); +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"); + +if (process.env["CSI_LIVE"]) { + const [html, json] = await Promise.all([ + fetch(urlClassifica()).then((r) => r.text()), + fetch(urlPartite()).then((r) => r.json()), + ]); + const live = parseClassifica(html); + const nostra = live.find((r) => isNostraSquadra(r.squadra)); + assert.ok(nostra, "la nostra squadra è in classifica"); + assert.ok(live.length >= 8 && nostra.giocate > 0); + 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}°`); +} + +console.log("csi-core: ok"); diff --git a/src/lib/csi-core.ts b/src/lib/csi-core.ts new file mode 100644 index 0000000..e4a232b --- /dev/null +++ b/src/lib/csi-core.ts @@ -0,0 +1,163 @@ +import type { RigaClassifica } from "./crapp-data"; + +/** + * Lettura dei dati ufficiali dal portale Livescore CSI Bologna. + * Non esiste un'API documentata: usiamo gli stessi endpoint che il sito chiama + * via ajax. Nessuna autenticazione, ma nessuna garanzia di stabilità: ogni + * funzione qui deve fallire in modo pulito (array vuoto), mai lanciare. + */ + +export const CSI_BASE = "https://livescore.csibologna.it"; +/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */ +export const CSI_PROJECT_ID = 767; +/** C.R.A.P. Volley sul portale CSI. */ +export const CSI_TEAM_ID = 3359; +export const CSI_GIRONE = "Girone B"; +export const CSI_NOME_SQUADRA = "C.R.A.P. Volley"; + +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}`; + +export type PartitaCsi = { + id: string; + data: string; + ora: string; + avversario: string; + casa: boolean; + /** null finché la gara non è stata giocata. */ + setNostri: number | null; + setLoro: number | null; + parziali: Array<[number, number]>; + campo: string; + competizione: string; +}; + +export type DatiCsi = { + classifica: RigaClassifica[]; + partite: PartitaCsi[]; + girone: string; + aggiornato: string; +}; + +/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */ +function normalizza(nome: string): string { + return nome.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +export function isNostraSquadra(nome: string): boolean { + return normalizza(nome) === normalizza(CSI_NOME_SQUADRA); +} + +const entita: Record| /gi) ?? []).map(testo);
+ if (celle.length < 10) continue;
+ const pos = Number(celle[0]);
+ if (!Number.isFinite(pos) || pos === 0 || !celle[1]) continue;
+ righe.push({
+ pos,
+ squadra: celle[1],
+ punti: Number(celle[2]) || 0,
+ giocate: Number(celle[3]) || 0,
+ vinte: Number(celle[4]) || 0,
+ perse: Number(celle[5]) || 0,
+ setFatti: Number(celle[8]) || 0,
+ setSubiti: Number(celle[9]) || 0,
+ });
+ }
+ return righe;
+}
+
+type EventoCsi = {
+ id?: number | string;
+ start?: string;
+ team1?: string;
+ team2?: string;
+ result?: string;
+ partials?: string;
+ field?: string;
+ project?: string;
+};
+
+function punteggio(result: string | undefined): [number, number] | null {
+ const m = /(\d+)\s*-\s*(\d+)/.exec(result ?? "");
+ return m ? [Number(m[1]), Number(m[2])] : null;
+}
+
+function parziali(partials: string | undefined): Array<[number, number]> {
+ return [...(partials ?? "").matchAll(/(\d+)\s*-\s*(\d+)/g)].map((m) => [
+ Number(m[1]),
+ Number(m[2]),
+ ]);
+}
+
+/** Converte gli eventi di `getEventsByTeamId.php` nel formato usato dall'app. */
+export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
+ if (!Array.isArray(eventi)) return [];
+ const partite: PartitaCsi[] = [];
+ for (const evento of eventi as EventoCsi[]) {
+ const team1 = testo(evento.team1 ?? "");
+ const team2 = testo(evento.team2 ?? "");
+ const casa = isNostraSquadra(team1);
+ if (!casa && !isNostraSquadra(team2)) continue;
+
+ const [dataIso, oraIso] = (evento.start ?? "").split("T");
+ if (!dataIso) continue;
+
+ const set = punteggio(evento.result);
+ const tutti = parziali(evento.partials);
+ partite.push({
+ id: String(evento.id ?? `${dataIso}-${team1}-${team2}`),
+ data: dataIso,
+ ora: (oraIso ?? "").slice(0, 5),
+ avversario: casa ? team2 : team1,
+ 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 ?? ""),
+ });
+ }
+ return partite.sort((a, b) => b.data.localeCompare(a.data));
+}
+
+/** Solo le gare già giocate, dalla più recente. */
+export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
+ return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
+}
diff --git a/src/lib/csi.ts b/src/lib/csi.ts
new file mode 100644
index 0000000..e9bf78c
--- /dev/null
+++ b/src/lib/csi.ts
@@ -0,0 +1,21 @@
+import { useQuery } from "@tanstack/react-query";
+import type { DatiCsi } from "./csi-core";
+
+export const CSI_KEY = ["csi"] as const;
+
+/**
+ * Classifica e risultati ufficiali CSI. Il server tiene una cache di 6 ore:
+ * qui basta una lettura per sessione.
+ */
+export function useCsi() {
+ return useQuery
-
{risultati.map((m) => (
>
);
-}
\ No newline at end of file
+}
|