From fee3d0b55a3d15bd70a0ff8b0e4d82b4054e4ca0 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 30 Aug 2026 12:20:51 +0200 Subject: [PATCH] Read the official standings and results from the CSI portal. The Campionato page showed hardcoded demo data. It now reads the real 2025/26 season (Campionato Open Misto Eccellenza, project 767, team 3359, Girone B) from the Livescore CSI Bologna portal. The portal has no documented API: we call the same endpoints its own pages call over ajax, so parsing must degrade gracefully. A single server route fetches them, caches for 6 hours and serves the last good payload on failure; the page falls back to the previous data when nothing is available. No browser ever contacts the portal, keeping the request count independent of how many players open the app. Also fixes the header, which claimed "Girone C - CSI Milano". Co-Authored-By: Claude Opus 5 --- docs/CHANGELOG.md | 7 ++ docs/ROADMAP.md | 6 +- docs/TODO.md | 3 +- docs/modules/collegamento-csi.md | 96 ++++++++++++++++++ src/lib/csi-core.test.ts | 113 +++++++++++++++++++++ src/lib/csi-core.ts | 163 +++++++++++++++++++++++++++++++ src/lib/csi.ts | 21 ++++ src/routeTree.gen.ts | 21 ++++ src/routes/api/public/csi.ts | 56 +++++++++++ src/routes/classifica.tsx | 57 ++++++++--- 10 files changed, 523 insertions(+), 20 deletions(-) create mode 100644 docs/modules/collegamento-csi.md create mode 100644 src/lib/csi-core.test.ts create mode 100644 src/lib/csi-core.ts create mode 100644 src/lib/csi.ts create mode 100644 src/routes/api/public/csi.ts 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 = ` + + + + +
PosSquadra
1Altra Squadra Girone A401613321421824
+ + + + + + + + + + + +
PosSquadra
1Paolo Poggi Volley & C.401613321431726
4 + C.R.A.P. Volley 3016106333628814271329980.561.07
`; + +const righe = parseClassifica(htmlClassifica); +assert.equal(righe.length, 2, "prende solo il girone che contiene la nostra squadra"); +assert.deepEqual(righe[1], { + pos: 4, + squadra: "C.R.A.P. Volley", + punti: 30, + giocate: 16, + vinte: 10, + perse: 6, + setFatti: 36, + setSubiti: 28, +}); +assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML"); +assert.deepEqual(parseClassifica("

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 - 23
23 - 25
25 - 18
25 - 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 - 21
18 - 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 = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + nbsp: " ", + deg: "°", + apos: "'", +}; + +function testo(html: string): string { + return html + .replace(/<[^>]*>/g, "") + .replace(/&(#\d+|[a-z]+);/gi, (intero, codice: string) => + codice.startsWith("#") + ? String.fromCharCode(Number(codice.slice(1))) + : (entita[codice.toLowerCase()] ?? intero), + ) + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Estrae la classifica dal frammento HTML di `project-sheets.php`. + * Il campionato ha due gironi: prendiamo la tabella che contiene la nostra + * squadra. Colonne (indice del ``): 0 Pos · 1 Squadra · 2 Punti · + * 3 Giocate · 4 Vinte · 5 Perse · 8 Set fatti · 9 Set subiti. + */ +export function parseClassifica(html: string): RigaClassifica[] { + const tabelle = html.match(//gi) ?? []; + const nostra = tabelle.find((t) => normalizza(testo(t)).includes(normalizza(CSI_NOME_SQUADRA))); + if (!nostra) return []; + + const righe: RigaClassifica[] = []; + for (const riga of nostra.match(//gi) ?? []) { + const celle = (riga.match(//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({ + queryKey: CSI_KEY, + queryFn: async () => { + const res = await fetch("/api/public/csi"); + if (!res.ok) throw new Error("CSI non disponibile"); + return res.json(); + }, + staleTime: 6 * 60 * 60_000, + retry: 1, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 85d0be3..a7a0bda 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,6 +19,7 @@ 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 PartitaIdRouteImport } from './routes/partita.$id' +import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' import { Route as ApiPublicPushMessaggioRouteImport } from './routes/api/public/push-messaggio' @@ -75,6 +76,11 @@ const PartitaIdRoute = PartitaIdRouteImport.update({ path: '/partita/$id', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({ + id: '/api/public/csi', + path: '/api/public/csi', + getParentRoute: () => rootRouteImport, +} as any) const ApiPublicPromemoriaPalloniRoute = ApiPublicPromemoriaPalloniRouteImport.update({ id: '/api/public/promemoria-palloni', @@ -114,6 +120,7 @@ export interface FileRoutesByFullPath { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -131,6 +138,7 @@ export interface FileRoutesByTo { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -149,6 +157,7 @@ export interface FileRoutesById { '/squadra': typeof SquadraRoute '/allenamento/$id': typeof AllenamentoIdRoute '/partita/$id': typeof PartitaIdRoute + '/api/public/csi': typeof ApiPublicCsiRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-messaggio': typeof ApiPublicPushMessaggioRoute @@ -168,6 +177,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -185,6 +195,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -202,6 +213,7 @@ export interface FileRouteTypes { | '/squadra' | '/allenamento/$id' | '/partita/$id' + | '/api/public/csi' | '/api/public/promemoria-palloni' | '/api/public/push-config' | '/api/public/push-messaggio' @@ -220,6 +232,7 @@ export interface RootRouteChildren { SquadraRoute: typeof SquadraRoute AllenamentoIdRoute: typeof AllenamentoIdRoute PartitaIdRoute: typeof PartitaIdRoute + ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushMessaggioRoute: typeof ApiPublicPushMessaggioRoute @@ -299,6 +312,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PartitaIdRouteImport parentRoute: typeof rootRouteImport } + '/api/public/csi': { + id: '/api/public/csi' + path: '/api/public/csi' + fullPath: '/api/public/csi' + preLoaderRoute: typeof ApiPublicCsiRouteImport + parentRoute: typeof rootRouteImport + } '/api/public/promemoria-palloni': { id: '/api/public/promemoria-palloni' path: '/api/public/promemoria-palloni' @@ -348,6 +368,7 @@ const rootRouteChildren: RootRouteChildren = { SquadraRoute: SquadraRoute, AllenamentoIdRoute: AllenamentoIdRoute, PartitaIdRoute: PartitaIdRoute, + ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushMessaggioRoute: ApiPublicPushMessaggioRoute, diff --git a/src/routes/api/public/csi.ts b/src/routes/api/public/csi.ts new file mode 100644 index 0000000..c2751de --- /dev/null +++ b/src/routes/api/public/csi.ts @@ -0,0 +1,56 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { + CSI_GIRONE, + parseClassifica, + partiteDaEventi, + urlClassifica, + urlPartite, + type DatiCsi, +} from "@/lib/csi-core"; + +const SCADENZA_MS = 6 * 60 * 60 * 1000; + +// ponytail: cache in memoria del processo, si perde ai cold start e non è +// condivisa tra istanze. Basta per una squadra; se il portale CSI diventa +// lento o le richieste crescono, spostare i dati in una tabella Supabase +// riempita da un job cron (stesso pattern di promemoria-palloni). +let cache: DatiCsi | undefined; +let scadenza = 0; + +async function scarica(url: string): Promise { + 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 leggiCsi(): Promise { + const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]); + const classifica = parseClassifica(html); + const partite = partiteDaEventi(JSON.parse(json)); + if (classifica.length === 0 && partite.length === 0) { + throw new Error("CSI: risposta senza classifica né partite"); + } + return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() }; +} + +export const Route = createFileRoute("/api/public/csi")({ + server: { + handlers: { + GET: async () => { + if (cache && Date.now() < scadenza) return Response.json(cache); + try { + cache = await leggiCsi(); + scadenza = Date.now() + SCADENZA_MS; + } catch (error) { + console.error("csi", error); + // Meglio un dato vecchio che nessun dato: il portale CSI cambia raramente. + if (!cache) return new Response("CSI non raggiungibile", { status: 503 }); + } + return Response.json(cache); + }, + }, + }, +}); diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index ea29d39..30ba547 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -4,6 +4,8 @@ import { cn } from "@/lib/utils"; import { PageHeader, Section } from "@/components/crapp/ui-bits"; import { storicoMatch } from "@/lib/crapp-data"; import { classificaConScout, useScoutMatches } from "@/lib/scout-store"; +import { useCsi } from "@/lib/csi"; +import { isNostraSquadra, partiteGiocate } from "@/lib/csi-core"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; export const Route = createFileRoute("/classifica")({ @@ -21,27 +23,50 @@ export const Route = createFileRoute("/classifica")({ component: Classifica, }); +function formatAggiornamento(iso: string) { + const d = new Date(iso); + const oggi = new Date().toDateString() === d.toDateString(); + const ora = d.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" }); + return oggi ? `oggi alle ${ora}` : `il ${d.toLocaleDateString("it-IT")} alle ${ora}`; +} + function Classifica() { const scoutMatches = useScoutMatches(); - const classifica = classificaConScout(scoutMatches); - const risultati = [ - ...scoutMatches.map((m) => ({ - id: m.id, - avversario: m.avversario, - casa: m.casa, - setNostri: m.setNostri, - setLoro: m.setLoro, - })), - ...storicoMatch, - ]; + const { data: csi } = useCsi(); + + const classifica = csi?.classifica.length ? csi.classifica : classificaConScout(scoutMatches); + const risultati = csi?.partite.length + ? partiteGiocate(csi.partite).map((p) => ({ + id: p.id, + avversario: p.avversario, + casa: p.casa, + setNostri: p.setNostri ?? 0, + setLoro: p.setLoro ?? 0, + })) + : [ + ...scoutMatches.map((m) => ({ + id: m.id, + avversario: m.avversario, + casa: m.casa, + setNostri: m.setNostri, + setLoro: m.setLoro, + })), + ...storicoMatch, + ]; + return ( <> - +
- Dati CSI aggiornati oggi alle 08:40 (demo) + {csi + ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` + : "Dati CSI in arrivo"}
@@ -58,7 +83,7 @@ function Classifica() { Pt
{classifica.map((r) => { - const noi = r.squadra === "CRAP Volley"; + const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; return (
-
+
{risultati.map((m) => (
); -} \ No newline at end of file +}