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 +}