4 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 c26b275e5c Porta la versione a 1.5.0: campionato CSI 2026/27
Changelog, PROJECT_STATE e package.json/lock alla 1.5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:26:17 +01:00
davideandClaude Sonnet 5.5 a378a795a4 Campionato CSI: stagione 2026/27
Nuovo project_id (933) e team_id (3878) sul portale Livescore. La Coppa non
esiste quest'anno: CSI_COPPA_PROJECT_ID diventa null e la route non la scarica
piu'. Lo storico 2025/26 non viene piu' mostrato. Test live adattati a inizio
stagione (nessuna gara giocata), doc e changelog aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:18:23 +01:00
davideandClaude Sonnet 5.5 a69bcb3f0d Porta la versione a 1.4.2: link Maps, testo «Apri su Maps»
Il pulsante delle schede di partita, allenamento ed evento dice «Apri su Maps».
CHANGELOG con la sezione 1.4.2 (m34 applicata in produzione il 26/10/2026),
PROJECT_STATE e package.json/package-lock alla 1.4.2.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:20:42 +01:00
davideandClaude Sonnet 5.5 19ddf715e6 Link Google Maps del luogo degli eventi (m34)
Colonna facoltativa eventi_app.link_maps con CHECK che accetta solo URL https di
Google Maps. Campo nel form di /eventi (admin e allenatori), validazione con
linkMapsValido() e test. Le schede di partita, allenamento ed evento mostrano
«Apri su Google Maps» solo se il link c'è; card e notifiche non cambiano.
Documentazione: calendario.md, DATABASE.md, CHANGELOG.

La migration m34 non è ancora applicata in produzione: va fatto db push prima di
rilasciare questo codice, che legge la nuova colonna.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:12:07 +01:00
20 changed files with 235 additions and 63 deletions

No files matched your search

+7 -4
View File
@@ -9,16 +9,19 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.4.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — gli eventi con convocati si vedono - **Versione:** 1.5.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — campionato CSI sulla
solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0: avviso «Presenza modificata» ad admin e stagione 2026/27 (nuovi `project_id` e `team_id`, nessuna Coppa). La 1.4.2: ogni evento può avere un link
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0 compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 44 file in `supabase/migrations/`, fino a `m33_presenza_modificata_allenatori`. Tutte applicate in produzione (verificato con `npx supabase migration list` il - **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
09/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie 26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. `foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
+28
View File
@@ -13,6 +13,34 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.5.0] - 2026-10-29
Campionato CSI 2026/27. Nessuna migration.
### Modificato
- Campionato CSI passato alla stagione 2026/27: nuovo `project_id` (933) e nuovo `team_id`
(3878). La Coppa non c'è quest'anno e la sezione resta nascosta; lo storico 2025/26 non viene
più mostrato.
### Dati
- Calendario di produzione aggiornato a mano con le 12 gare di campionato già definite dal CSI
(4/11/2026 – 11/3/2027): titolo «CAMPIONATO», luogo e link Google Maps dell'impianto. I 9
allenamenti nello stesso giorno di una gara sono stati eliminati.
## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
26/10/2026.
### Aggiunto
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
## [1.4.1] - 2026-10-19 ## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati. Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
+2 -2
View File
@@ -75,8 +75,8 @@ Colonne di `giocatori_squadra` che meritano una nota:
## Eventi e presenze ## Eventi e presenze
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. | | `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. | | `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | | `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
+1 -1
View File
@@ -31,7 +31,7 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
resta un'estensione futura resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data - [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048) di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2025/26) - [x] Collegamento CSI (stagione 2026/27)
- [x] Classifica automatica (campionato e Coppa) - [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato - [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria - [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
+7
View File
@@ -53,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`. `eventi_app`.
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
luogo, e né la card né le notifiche lo mostrano.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`) Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
+37 -37
View File
@@ -1,6 +1,6 @@
# Modulo — Collegamento CSI # Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26) **Stato:** implementato (stagione 2026/27)
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id` **Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti) (dettaglio di una gara: formazioni e scontri diretti)
@@ -28,52 +28,50 @@ verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati i
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente. un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26) ### Identificativi (stagione 2026/27)
| Cosa | Valore | | Cosa | Valore |
| --------------------- | -------------------------------------- | | --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza | | Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` | | `project_id` (girone) | `933` |
| Coppa | PVM Coppa CSI Misto Silver | | Coppa | nessuna: la squadra non è iscritta |
| `project_id` (coppa) | `848` | | `project_id` (coppa) | `null` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | | Squadra sul portale | `C.R.A.P. Volley` |
| `team_id` | `3359` | | `team_id` | `3878` |
| Girone | B | | Girone | B (7 squadre) |
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in `project_id` (933), `CSI_COPPA_PROJECT_ID` (`null`) e `team_id` (3878) 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 **A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono `3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica` per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel `3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica classifica è tutta a zero (giocate 0): non è un errore di parsing.
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../decisions/DD-032.md)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359` Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
(dietro `team_details.php`) contiene una sezione "Campionati" con un link `team_id` si legge dal link della squadra nella pagina del girone) oppure
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta — `components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
verificato chiamando l'endpoint direttamente, che oggi restituisce sia `league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire `933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
`team_details.php` nel browser, questo componente basta. `CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
### Endpoint usati dall'app ### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente | | Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- | | -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") | | `components/project-sheets.php?project_id=933` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=933` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") | | `components/project-sheets.php?project_id=<Coppa>` | HTML | Classifica del girone di Coppa, solo se `CSI_COPPA_PROJECT_ID` non è `null` (vedi limite 4) | `league_details.php?project_id=<Coppa>` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) | | `assets/json/getEventsByTeamId.php?team_id=3878` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3878` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`, **Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra). `parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse · `team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti `6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767` fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è (`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa. condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e** **Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
@@ -247,9 +245,10 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`. sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
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` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`. id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non `csi-core.ts`, e lo storico della stagione precedente sparisce (DD-032 non lo conserva).
L'etichetta «Stagione 2026/27» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano 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`. 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
@@ -258,20 +257,21 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
dati in una tabella Supabase riempita da un job cron (stesso pattern di dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`). `promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.** 4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella `getEventsByTeamId.php?team_id=3878` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project` endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`. filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in **La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa `/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona 2025/26 era `project_id=848`: `project-sheets.php` ha la stessa
struttura a tabella-per-girone del campionato, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id` andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto: figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le `project-main.php?project_id=848` (2025/26) descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.5.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.1", "version": "1.5.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+3
View File
@@ -145,6 +145,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo: string; luogo: string;
link_maps: string | null;
note: string; note: string;
ora: string; ora: string;
pagelle_chiuse: boolean; pagelle_chiuse: boolean;
@@ -160,6 +161,7 @@ export type Database = {
data: string; data: string;
id: string; id: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
@@ -175,6 +177,7 @@ export type Database = {
data?: string; data?: string;
id?: string; id?: string;
luogo?: string; luogo?: string;
link_maps?: string | null;
note?: string; note?: string;
ora?: string; ora?: string;
pagelle_chiuse?: boolean; pagelle_chiuse?: boolean;
+10 -9
View File
@@ -8,16 +8,17 @@ import type { RigaClassifica } from "./crapp-data";
*/ */
export const CSI_BASE = "https://livescore.csibologna.it"; export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */ /** Campionato Open Misto Eccellenza 2026/27. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767; export const CSI_PROJECT_ID = 933;
/** /**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone * Coppa CSI della stagione in corso, `null` se la squadra non è iscritta (nel 2026/27 non c'è).
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a * Stesso formato di tabella del campionato (girone all'italiana), solo con meno squadre:
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md. * `parseClassifica()` funziona invariata. Cambia a ogni stagione come CSI_PROJECT_ID, vedi
* docs/modules/collegamento-csi.md.
*/ */
export const CSI_COPPA_PROJECT_ID = 848; export const CSI_COPPA_PROJECT_ID: number | null = null;
/** C.R.A.P. Volley sul portale CSI. */ /** C.R.A.P. Volley sul portale CSI: l'id cambia a ogni stagione (2025/26 era 3359). */
export const CSI_TEAM_ID = 3359; export const CSI_TEAM_ID = 3878;
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";
@@ -133,7 +134,7 @@ export type DettaglioPartitaCsi = {
export type DatiCsi = { export type DatiCsi = {
classifica: RigaClassifica[]; classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */ /** Classifica del girone di Coppa (`CSI_COPPA_PROJECT_ID`). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[]; classificaCoppa: RigaClassifica[];
partite: PartitaCsi[]; partite: PartitaCsi[];
girone: string; girone: string;
+1 -1
View File
@@ -1,7 +1,7 @@
import { daRiga, type Evento, type RigaEvento } from "./eventi"; import { daRiga, type Evento, type RigaEvento } from "./eventi";
const COLONNE = const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse"; "id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
/** Lettura eventi lato server (route API): stessa conversione del client. */ /** Lettura eventi lato server (route API): stessa conversione del client. */
export async function leggiEventi(): Promise<Evento[]> { export async function leggiEventi(): Promise<Evento[]> {
+18 -1
View File
@@ -9,6 +9,8 @@ export type Evento = {
tipo: EventoTipo; tipo: EventoTipo;
titolo: string; titolo: string;
luogo: string; luogo: string;
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
linkMaps?: string | undefined;
data: string; data: string;
ora: string; ora: string;
note: string; note: string;
@@ -30,6 +32,7 @@ export type RigaEvento = {
tipo: string; tipo: string;
titolo: string; titolo: string;
luogo: string; luogo: string;
link_maps?: string | null;
data: string; data: string;
ora: string; ora: string;
note: string | null; note: string | null;
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
tipo: (r.tipo as EventoTipo) ?? "evento", tipo: (r.tipo as EventoTipo) ?? "evento",
titolo: r.titolo, titolo: r.titolo,
luogo: r.luogo ?? "", luogo: r.luogo ?? "",
linkMaps: r.link_maps ?? "",
data: r.data, data: r.data,
ora: r.ora ?? "", ora: r.ora ?? "",
note: r.note ?? "", note: r.note ?? "",
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
} }
const COLONNE = const COLONNE =
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il"; "id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */ /** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento"; export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
@@ -76,6 +80,17 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
return { tipo: c, campionato: false }; return { tipo: c, campionato: false };
} }
/**
* Il link è accettato solo se punta a Google Maps (stesso criterio del CHECK su
* `eventi_app.link_maps`): è un URL che gli utenti toccano, quindi niente `javascript:` né siti terzi.
*/
const LINK_MAPS =
/^https:\/\/(maps\.app\.goo\.gl\/|goo\.gl\/maps\/|maps\.google\.[a-z.]+\/|(www\.)?google\.[a-z.]+\/maps)\S*$/;
export function linkMapsValido(url: string): boolean {
return url.length <= 500 && LINK_MAPS.test(url);
}
export const EVENTI_KEY = ["eventi"] as const; export const EVENTI_KEY = ["eventi"] as const;
async function fetchEventi(): Promise<Evento[]> { async function fetchEventi(): Promise<Evento[]> {
@@ -124,6 +139,7 @@ export function eventoVuoto(): Evento {
tipo: "allenamento", tipo: "allenamento",
titolo: "", titolo: "",
luogo: "", luogo: "",
linkMaps: "",
data: new Date().toISOString().slice(0, 10), data: new Date().toISOString().slice(0, 10),
ora: "20:30", ora: "20:30",
note: "", note: "",
@@ -144,6 +160,7 @@ export function useSalvaEvento() {
tipo: evento.tipo, tipo: evento.tipo,
titolo: evento.titolo, titolo: evento.titolo,
luogo: evento.luogo, luogo: evento.luogo,
link_maps: evento.linkMaps?.trim() || null,
data: evento.data, data: evento.data,
ora: evento.ora, ora: evento.ora,
note: evento.note, note: evento.note,
+10
View File
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
+3 -1
View File
@@ -33,7 +33,9 @@ async function leggiCsi(): Promise<DatiCsi> {
scarica(urlClassifica()), scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico // Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto). // come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""), CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()), scarica(urlPartite()),
]); ]);
const classifica = parseClassifica(html); const classifica = parseClassifica(html);
+24 -1
View File
@@ -22,6 +22,7 @@ import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
eventoVuoto, eventoVuoto,
linkMapsValido,
useEliminaEvento, useEliminaEvento,
useSalvaEvento, useSalvaEvento,
type CategoriaEvento, type CategoriaEvento,
@@ -111,6 +112,12 @@ function GestioneEventi() {
toast.error("Serve un titolo per l'evento"); toast.error("Serve un titolo per l'evento");
return; return;
} }
if ((bozza.linkMaps ?? "").trim() && !linkMapsValido(bozza.linkMaps!.trim())) {
toast.error(
"Il link deve essere di Google Maps (https://maps.app.goo.gl/… o google.com/maps…)",
);
return;
}
if (modificaEsistente) { if (modificaEsistente) {
setConfermaModifica(true); setConfermaModifica(true);
return; return;
@@ -121,7 +128,11 @@ function GestioneEventi() {
async function conferma() { async function conferma() {
if (!bozza) return; if (!bozza) return;
try { try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() }); await salva.mutateAsync({
...bozza,
titolo: bozza.titolo.trim(),
linkMaps: (bozza.linkMaps ?? "").trim(),
});
toast.success("Evento salvato"); toast.success("Evento salvato");
setBozza(null); setBozza(null);
} catch { } catch {
@@ -373,6 +384,18 @@ function GestioneEventi() {
/> />
</Campo> </Campo>
<Campo label="Link Google Maps (facoltativo)">
<input
type="url"
inputMode="url"
value={bozza.linkMaps ?? ""}
maxLength={500}
placeholder="Incolla il link da Condividi → Copia link"
onChange={(e) => aggiorna({ linkMaps: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Note"> <Campo label="Note">
<textarea <textarea
value={bozza.note} value={bozza.note}
+10
View File
@@ -72,6 +72,16 @@ function EventoDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
+10
View File
@@ -133,6 +133,16 @@ function PartitaDetail() {
<span className="ml-4 inline-flex items-center gap-2"> <span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo} <MapPin className="h-4 w-4" /> {evento.luogo}
</span> </span>
{evento.linkMaps ? (
<a
href={evento.linkMaps}
target="_blank"
rel="noopener noreferrer"
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
>
<MapPin className="h-4 w-4" /> Apri su Maps
</a>
) : null}
</div> </div>
{evento.note ? ( {evento.note ? (
@@ -0,0 +1,16 @@
-- M34 — Link Google Maps del luogo di un evento
--
-- Colonna facoltativa: NULL = nessun link. Il CHECK accetta solo URL https di Google Maps (stesso
-- criterio di `linkMapsValido()` in src/lib/eventi.ts), perché il link è mostrato a tutta la squadra
-- come pulsante. Chi lo scrive è già limitato ad admin e allenatori dalle policy di `eventi_app` (M11).
ALTER TABLE public.eventi_app
ADD COLUMN link_maps text,
ADD CONSTRAINT eventi_app_link_maps_google
CHECK (
link_maps IS NULL
OR (
length(link_maps) <= 500
AND link_maps ~ '^https://(maps\.app\.goo\.gl/|goo\.gl/maps/|maps\.google\.[a-z.]+/|(www\.)?google\.[a-z.]+/maps)\S*$'
)
);
+14 -3
View File
@@ -333,26 +333,34 @@ assert.equal(
if (process.env["CSI_LIVE"]) { if (process.env["CSI_LIVE"]) {
const [html, htmlCoppa, json] = await Promise.all([ const [html, htmlCoppa, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()), fetch(urlClassifica()).then((r) => r.text()),
fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()), CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()),
fetch(urlPartite()).then((r) => r.json()), fetch(urlPartite()).then((r) => r.json()),
]); ]);
const live = parseClassifica(html); const live = parseClassifica(html);
const nostra = live.find((r) => isNostraSquadra(r.squadra)); const nostra = live.find((r) => isNostraSquadra(r.squadra));
assert.ok(nostra, "la nostra squadra è in classifica"); assert.ok(nostra, "la nostra squadra è in classifica");
assert.ok(live.length >= 8 && nostra.giocate > 0); assert.ok(live.length >= 7); // a inizio stagione le giocate sono ancora 0
const liveCoppa = parseClassifica(htmlCoppa); const liveCoppa = parseClassifica(htmlCoppa);
if (CSI_COPPA_PROJECT_ID !== null) {
assert.ok( assert.ok(
liveCoppa.some((r) => isNostraSquadra(r.squadra)), liveCoppa.some((r) => isNostraSquadra(r.squadra)),
"la nostra squadra è nel girone di Coppa", "la nostra squadra è nel girone di Coppa",
); );
}
const livePartite = partiteDaEventi(json); const livePartite = partiteDaEventi(json);
const liveGiocate = partiteGiocate(livePartite); const liveGiocate = partiteGiocate(livePartite);
assert.ok(livePartite.length > 0 && liveGiocate.length > 0); assert.ok(livePartite.length > 0, "calendario presente"); // liveGiocate può essere vuoto a inizio stagione
assert.ok( assert.ok(
liveGiocate.every((p) => p.link.startsWith("https://")), liveGiocate.every((p) => p.link.startsWith("https://")),
"ogni partita ha un referto", "ogni partita ha un referto",
); );
// A inizio stagione nessuna gara è ancora giocata: niente referto da leggere.
if (liveGiocate.length === 0) {
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, nessuna giocata`);
} else {
const primaGiocata = liveGiocate[0]!; const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([ const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()), fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
@@ -369,6 +377,7 @@ if (process.env["CSI_LIVE"]) {
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` + `${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`, `formazione ${liveFormazioni!.noi.titolari.length} titolari`,
); );
}
} }
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------ // --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
@@ -377,6 +386,8 @@ assert.equal(stagioneDi("2026-05-20"), "2025/26");
assert.equal(stagioneDi("2026-07-31"), "2025/26"); assert.equal(stagioneDi("2026-07-31"), "2025/26");
assert.equal(stagioneDi("2026-08-01"), "2026/27"); assert.equal(stagioneDi("2026-08-01"), "2026/27");
assert.equal(stagioneDi("2099-10-01"), "2099/00"); assert.equal(stagioneDi("2099-10-01"), "2099/00");
assert.equal(stagioneDi("2026-11-04"), "2026/27", "prima gara del campionato 2026/27");
assert.equal(stagioneDi("2027-03-11"), "2026/27", "ultima gara del campionato 2026/27");
assert.equal(stagioneDaPartite([]), null); assert.equal(stagioneDaPartite([]), null);
assert.equal( assert.equal(
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]), stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
+31
View File
@@ -11,6 +11,7 @@ import {
eventoVisibile, eventoVisibile,
rosaConvocata, rosaConvocata,
eventoVuoto, eventoVuoto,
linkMapsValido,
type RigaEvento, type RigaEvento,
} from "@/lib/eventi"; } from "@/lib/eventi";
@@ -35,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
tipo: "partita", tipo: "partita",
titolo: "CRAP - Avversari", titolo: "CRAP - Avversari",
luogo: "PalaCRAP", luogo: "PalaCRAP",
linkMaps: "",
data: "2026-09-01", data: "2026-09-01",
ora: "21:00", ora: "21:00",
note: "portare la seconda maglia", note: "portare la seconda maglia",
@@ -172,3 +174,32 @@ for (const { nome, convocati, attesi } of casi) {
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`); assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
} }
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota"); assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
// Link Google Maps: solo URL https di Google Maps, e vuoto se la riga non ne ha uno.
assert.equal(daRiga(riga).linkMaps, "");
assert.equal(daRiga({ ...riga, link_maps: null }).linkMaps, "");
assert.equal(
daRiga({ ...riga, link_maps: "https://maps.app.goo.gl/abc123" }).linkMaps,
"https://maps.app.goo.gl/abc123",
);
assert.equal(eventoVuoto().linkMaps, "");
for (const ok of [
"https://maps.app.goo.gl/abc123",
"https://goo.gl/maps/xyz",
"https://www.google.com/maps/place/PalaCRAP",
"https://google.it/maps?q=Cesena",
"https://maps.google.com/?q=Cesena",
]) {
assert.equal(linkMapsValido(ok), true, ok);
}
for (const ko of [
"",
"javascript:alert(1)",
"http://maps.app.goo.gl/abc123",
"https://example.com/maps",
"https://maps.app.goo.gl.evil.com/abc",
"https://www.google.com/search?q=maps",
"https://maps.app.goo.gl/a b",
]) {
assert.equal(linkMapsValido(ko), false, ko);
}