9 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
davide 2f583ca97f Merge develop: versione 1.4.1 2026-10-19 08:31:53 +02:00
davideandClaude Sonnet 5.5 7996dd050e CHANGELOG: la correzione della card evento è parte della 1.4.1
Sposta la voce da «Non rilasciato» alla sezione 1.4.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:31:10 +02:00
davideandClaude Sonnet 5.5 5ee148b88b Card evento filtrata sui convocati + copertura test per numero di convocati
- La card (Calendario e Home) conta «presenti/convocati» e mostra i pulsanti di presenza solo
  ai convocati; «Da confermare» in Home esclude gli eventi a cui non si è convocati.
- Nuove eConvocato() e rosaConvocata(): il filtro, prima ripetuto in tre punti, è una funzione
  pura usata da card, «Rosa e presenze» e convocatiEvento.
- Test per 0, 1, 2, 3, tutti e id sconosciuti su visibilità, convocazione e solleciti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:29:36 +02:00
davideandClaude Sonnet 5.5 8def97731c Porta la versione a 1.4.1: eventi visibili solo ai convocati
Sezione 1.4.1 del CHANGELOG, versione in package.json e package-lock.json,
PROJECT_STATE aggiornato. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:14:17 +02:00
davideandClaude Sonnet 5.5 5ca081a6af Eventi visibili solo ai convocati; «Rosa e presenze» filtrata sui convocati
- «Rosa e presenze» (allenamento, partita, evento) elenca, conta e solleciti solo i convocati
  invece di tutta la rosa; il riquadro «La tua risposta» sparisce per i non convocati.
- Calendario, Home e elenco eventi mostrano un evento con convocati solo a chi è convocato;
  admin e allenatori lo vedono sempre, `convocati` vuoto = tutti (eventoVisibile).
- Filtro solo lato interfaccia: la RLS di eventi_app non cambia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:12:02 +02:00
26 changed files with 416 additions and 80 deletions

No files matched your search

+7 -3
View File
@@ -9,15 +9,19 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.4.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — avviso «Presenza modificata» ad admin e - **Versione:** 1.5.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — campionato CSI sulla
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
+50
View File
@@ -13,6 +13,56 @@ 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
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
Nessuna migration.
### Modificato
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
### Corretto
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
vuoto resta «tutta la rosa».
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
eventi a cui non sei convocato.
## [1.4.0] - 2026-10-09 ## [1.4.0] - 2026-10-09
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento. Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
+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.0", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.4.0", "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.0", "version": "1.5.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+4 -3
View File
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store"; import { useGiocatoreInCampo } from "@/lib/user-store";
@@ -75,7 +75,8 @@ export function EventoCard({
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme. // Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter(inRosa); // Solo i convocati contano e rispondono (vuoto = tutta la rosa).
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
const stato = io ? risposte[io.id] : undefined; const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter( const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -187,7 +188,7 @@ export function EventoCard({
</p> </p>
) : null} ) : null}
{io ? ( {io && eConvocato(evento, io.id) ? (
<div <div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")} className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group" role="group"
+7 -3
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data"; import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import type { EventoTipo } from "@/lib/eventi"; import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store"; import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -20,9 +20,12 @@ export function RosaPresenze({
eventoId, eventoId,
data, data,
tipo = "allenamento", tipo = "allenamento",
convocati = [],
}: { }: {
eventoId: string; eventoId: string;
data: string; data: string;
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
convocati?: string[];
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */ /** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo; tipo?: EventoTipo;
}) { }) {
@@ -34,7 +37,8 @@ export function RosaPresenze({
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034). // Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase(); const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi(); const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa(); const anagrafica = useAnagraficaRosa();
const rosa = rosaConvocata(anagrafica, convocati);
const [sollecito, setSollecito] = useState(false); const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi(); const passato = data < dataOggi();
@@ -97,7 +101,7 @@ export function RosaPresenze({
</span> </span>
</div> </div>
{io ? ( {io && rosa.some((g) => g.id === io.id) ? (
<div className="mt-4 border-t border-border pt-3"> <div className="mt-4 border-t border-border pt-3">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta La tua risposta
+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;
+14
View File
@@ -0,0 +1,14 @@
import { eventoVisibile, useEventi } from "./eventi";
import { usePuoGestireEventi } from "./ruoli";
import { useGiocatoreId } from "./user-store";
/** Gli eventi che l'utente corrente può vedere (calendario, home, elenco). */
export function useEventiVisibili() {
const { eventi, ...resto } = useEventi();
const giocatoreId = useGiocatoreId();
const vedeTutto = usePuoGestireEventi();
return {
...resto,
eventi: eventi.filter((e) => eventoVisibile(e, giocatoreId, vedeTutto)),
};
}
+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[]> {
+44 -3
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[]> {
@@ -90,6 +105,25 @@ export function useEventi() {
return { ...query, eventi: query.data ?? [] }; return { ...query, eventi: query.data ?? [] };
} }
/** Il giocatore è tra i convocati (elenco vuoto = tutta la rosa). */
export function eConvocato(evento: Pick<Evento, "convocati">, giocatoreId: string): boolean {
return evento.convocati.length === 0 || evento.convocati.includes(giocatoreId);
}
/**
* Chi vede un evento: i convocati; se `convocati` è vuoto, tutta la rosa. Chi gestisce gli
* eventi (admin e allenatori) li vede sempre, convocato o no.
*/
export function eventoVisibile(
evento: Pick<Evento, "convocati">,
giocatoreId: string | null,
vedeTutto: boolean,
): boolean {
if (vedeTutto) return true;
if (evento.convocati.length === 0) return true;
return giocatoreId !== null && eConvocato(evento, giocatoreId);
}
export function useEvento(id: string) { export function useEvento(id: string) {
const { eventi, ...resto } = useEventi(); const { eventi, ...resto } = useEventi();
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null }; return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
@@ -105,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: "",
@@ -125,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,
@@ -198,6 +234,11 @@ export function compleanniEventi(
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */ /** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) { export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
if (!evento || evento.convocati.length === 0) return rosa; return rosaConvocata(rosa, evento?.convocati ?? []);
return rosa.filter((g) => evento.convocati.includes(g.id)); }
/** Restringe una rosa ai convocati indicati: elenco vuoto = tutta la rosa. */
export function rosaConvocata<T extends { id: string }>(rosa: T[], convocati: string[]): T[] {
if (convocati.length === 0) return rosa;
return rosa.filter((g) => convocati.includes(g.id));
} }
+16 -1
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 ? (
@@ -93,7 +103,12 @@ function AllenamentoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
+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);
+3 -2
View File
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion"; import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { usePuoGestireEventi } from "@/lib/ruoli"; import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
@@ -56,7 +57,7 @@ function Calendario() {
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null); const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false); const [drawerAperto, setDrawerAperto] = useState(false);
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const rosa = useAnagraficaRosa({ conAllenatori: true }); const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo // `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi. // `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
+26 -3
View File
@@ -1,3 +1,4 @@
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react"; import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
@@ -21,8 +22,8 @@ import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
eventoVuoto, eventoVuoto,
linkMapsValido,
useEliminaEvento, useEliminaEvento,
useEventi,
useSalvaEvento, useSalvaEvento,
type CategoriaEvento, type CategoriaEvento,
type Evento, type Evento,
@@ -65,7 +66,7 @@ function GestioneEventi() {
const io = useGiocatoreBase(); const io = useGiocatoreBase();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso. // Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi(); const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento(); const salva = useSalvaEvento();
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
@@ -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}
+16 -1
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 ? (
@@ -88,7 +98,12 @@ function EventoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
+4 -3
View File
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero"; import { Numero } from "@/components/motion/Numero";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi"; import { eConvocato, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useRispostePresenze } from "@/lib/presenze"; import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa"; import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra"; import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
@@ -47,14 +48,14 @@ function Index() {
const base = useGiocatoreBase(); const base = useGiocatoreBase();
const giocatore = useIo(); const giocatore = useIo();
const allenatore = isAllenatore(base); const allenatore = isAllenatore(base);
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10); const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3); const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null; const prossimo = prossimi[0] ?? null;
const daConfermare = prossimi const daConfermare = prossimi
.slice(1) .slice(1)
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]); .filter((e) => !giocatore || (eConvocato(e, giocatore.id) && !presenze[e.id]?.[giocatore.id]));
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null; const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const { data: csi } = useCsi(); const { data: csi } = useCsi();
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra)); const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
+16 -1
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 ? (
@@ -297,7 +307,12 @@ function PartitaDetail() {
) : null} ) : null}
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
@@ -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: "" }]),
+80
View File
@@ -7,7 +7,11 @@ import {
convocatiEvento, convocatiEvento,
daCategoria, daCategoria,
daRiga, daRiga,
eConvocato,
eventoVisibile,
rosaConvocata,
eventoVuoto, eventoVuoto,
linkMapsValido,
type RigaEvento, type RigaEvento,
} from "@/lib/eventi"; } from "@/lib/eventi";
@@ -32,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",
@@ -123,3 +128,78 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
assert.notEqual(bozza.id, "", "ogni bozza ha un id"); assert.notEqual(bozza.id, "", "ogni bozza ha un id");
console.log("eventi: ok"); console.log("eventi: ok");
// --- eventoVisibile: i non convocati non vedono l'evento, chi gestisce sì -----
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g1", false), true, "il convocato lo vede");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", false), false, "il non convocato no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, null, false), false, "senza slot no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", true), true, "admin/allenatore sempre");
assert.equal(eventoVisibile({ convocati: [] }, "g2", false), true, "vuoto = tutta la rosa");
// --- eConvocato: elenco vuoto = tutti ----------------------------------------
assert.equal(eConvocato({ convocati: ["g1"] }, "g1"), true, "convocato");
assert.equal(eConvocato({ convocati: ["g1"] }, "g2"), false, "non convocato");
assert.equal(eConvocato({ convocati: [] }, "g2"), true, "vuoto = tutti");
// --- Copertura per numero di convocati: 1, 2, 3, tutti, nessuno, id sconosciuti -
const squadra = ["g1", "g2", "g3", "g4"].map((id) => ({ id }));
const ids = (l: Array<{ id: string }>) => l.map((g) => g.id);
const casi: Array<{ nome: string; convocati: string[]; attesi: string[] }> = [
{ nome: "nessuno indicato = tutta la rosa", convocati: [], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "solo 1", convocati: ["g2"], attesi: ["g2"] },
{ nome: "2", convocati: ["g1", "g4"], attesi: ["g1", "g4"] },
{ nome: "3", convocati: ["g3", "g1", "g2"], attesi: ["g1", "g2", "g3"] },
{ nome: "tutti indicati", convocati: ["g1", "g2", "g3", "g4"], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "id sconosciuto ignorato", convocati: ["g2", "zz"], attesi: ["g2"] },
{ nome: "solo ids sconosciuti: nessuno", convocati: ["zz"], attesi: [] },
];
for (const { nome, convocati, attesi } of casi) {
assert.deepEqual(ids(rosaConvocata(squadra, convocati)), attesi, `rosaConvocata: ${nome}`);
const conv = { convocati };
for (const { id } of squadra) {
const dentro = attesi.includes(id) || convocati.length === 0;
assert.equal(
eConvocato(conv, id),
convocati.length === 0 || convocati.includes(id),
`eConvocato ${id}: ${nome}`,
);
assert.equal(eventoVisibile(conv, id, false), dentro, `visibile a ${id}: ${nome}`);
assert.equal(
eventoVisibile(conv, id, true),
true,
`admin/allenatore vede sempre (${id}): ${nome}`,
);
}
assert.equal(eventoVisibile(conv, null, false), convocati.length === 0, `senza slot: ${nome}`);
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
}
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);
}
+32
View File
@@ -294,3 +294,35 @@ assert.equal(
); );
console.log("presenze: ok"); console.log("presenze: ok");
// --- Solleciti per numero di convocati: 1, 2, 3, tutti ------------------------
{
const sq = (["g1", "g2", "g3", "g4"] as const).map((id) => ({
id,
attivo: true,
tipo: "giocatore" as const,
}));
const nessuna: Array<{ giocatore_id: string; stato: string }> = [];
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g2"]), ["g2"], "solo 1 convocato");
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g1", "g3"]), ["g1", "g3"], "2 convocati");
assert.deepEqual(
destinatariSollecito(sq, nessuna, ["g1", "g2", "g4"]),
["g1", "g2", "g4"],
"3 convocati",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g1", stato: "presente" }], ["g1", "g3"]),
["g3"],
"il convocato che ha già risposto non va sollecitato",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g4", stato: "presente" }], ["g1"]),
["g1"],
"chi ha risposto ma non è convocato non cambia i destinatari",
);
assert.deepEqual(
destinatariSollecito(sq, nessuna, []),
["g1", "g2", "g3", "g4"],
"nessuno = tutti",
);
}