22 Commits
Author SHA1 Message Date
davide a6defc1a49 Merge branch 'develop' into main: versione 1.1.0 con l'avviso certificati 2026-09-27 14:10:06 +02:00
davideandClaude Sonnet 5 0a2112a9f3 Porta la versione a 1.1.0 per l'avviso certificati (DD-035)
Aggiunta la sezione [1.1.0] nel changelog, spostata la voce da «Prossimo» a
«Fatto» nella roadmap e chiuso lo stato di DD-035 come implementata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:09:02 +02:00
davideandClaude Sonnet 5 a51cdb43e4 Implementa l'avviso certificati in scadenza in Home (DD-035)
Calcolato al volo da certificato_scadenza in profili-core.ts: nessuna tabella,
nessuna migration, nessuna push. Lo vedono solo gli admin (nomi di tutta la
rosa, non cliccabile) e il giocatore interessato (solo il proprio, apre
Documenti). Montato in Home subito dopo il promemoria palloni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:06:18 +02:00
davideandClaude Opus 5.5 49d4d9de31 Documenta l'avviso certificati in scadenza in Home (DD-035)
Specifica in profilo-giocatore.md: avviso giallo nei 7 giorni prima della
scadenza e nero dopo, con i nomi di tutta la rosa per gli admin (non
cliccabile) e solo il proprio per il giocatore interessato (apre Documenti).
Calcolato al volo da certificato_scadenza: nessuna migration, nessuna push.
Aggiornati roadmap, stato del progetto, allenatore e notifiche.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:43:26 +02:00
davideandClaude Opus 5.5 9cc4c7e47a Merge branch 'develop' into main: versione 1.0.0 con il ruolo allenatore
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:03:28 +02:00
davideandClaude Opus 5.5 46bcb21cdb Porta la versione a 1.0.0 e documenta nel changelog il lavoro dalla 0.9.2
La sezione [1.0.0] riunisce i commit dopo il tag v0.9.2: ruolo allenatore,
centro notifiche in-app, messaggi dell'admin, Gestione eventi a calendario,
script di backup, data di nascita pubblica corretta. La nota sulle versioni
passa dal regime pre-1.0 al versionamento semantico.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:00:19 +02:00
davideandClaude Opus 5.5 15a23dfaf4 Aggiorna la documentazione del ruolo allenatore a implementato
La specifica in docs/modules/allenatore.md passa da «da implementare» a
«implementato», con Home, notifiche, dove si esclude l'allenatore e le scelte di
implementazione; DD-034 diventa Accettata. Voce in CHANGELOG (Non rilasciato) e
in ROADMAP tra le cose fatte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:54:43 +02:00
davideandClaude Opus 5.5 2ec72fd08f Implementa il ruolo allenatore nell'app
- Modello: tipo sugli slot, inRosa() al posto del filtro su attivo in tutte le
  letture della rosa di gioco (presenze, convocati, voti, palloni, classifiche,
  badge, sollecito); gli allenatori restano solo nei compleanni e in Squadra.
- Permessi: user_roles letto una volta per tutti i ruoli; usePuoGestireEventi()
  apre /eventi e il sollecito presenze anche all'allenatore, lato server con
  richiediGestoreEventi(). Palloni, sondaggio e messaggi restano all'admin.
- Identità: useGiocatoreInCampo() è null per l'allenatore, così non risponde alle
  presenze e non vota; useSonoAllenatore() decide cosa si vede.
- Profilo: per l'allenatore solo Docs (nome, cognome, nascita, telefono, email) e
  Opzioni; card avatar e tab Opzioni estratte per riusarle.
- Home, Squadra e partita: niente statistiche personali, badge, cacche e
  obiettivi per l'allenatore; in Rosa compare con la dicitura «Allenatore».
- Admin: tipo in «Aggiungi», allenatori a parte in Profili, fuori da conteggi ed
  export CSI.

Test unit su rosa di gioco e completamento, route test sull'allenatore.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:54:35 +02:00
davideandClaude Opus 5.5 15a010855e Aggiunge il ruolo allenatore al database (M20, M21)
L'allenatore è uno slot di giocatori_squadra con tipo = 'allenatore' (DD-034):
- M20 aggiunge il valore allenatore ad app_role, da solo perché Postgres non lascia
  usare un valore di enum nella transazione che lo crea;
- M21 aggiunge la colonna tipo, rende numero nullabile solo per gli allenatori,
  blocca tipo nel trigger sugli slot e lascia all'allenatore nome e cognome del
  proprio slot; un trigger tiene allineato il ruolo in user_roles al collegamento;
- eventi_app scrivibile da admin e allenatori; presenze, cacche e voti chiusi
  all'allenatore; i promemoria evento lo includono sempre.

Compatibile con la 0.9.2: senza allenatori registrati non cambia nulla. Già
applicata in produzione con supabase db push; g18 «Beta Tester» è allenatore.
Il test permessi-allenatore verifica le regole contro il database locale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:54:24 +02:00
davideandClaude Opus 5.5 dda4aff746 Documenta il ruolo allenatore (specifica, DD-034, roadmap)
L'allenatore è uno slot di giocatori_squadra con tipo diverso: registrato dall'admin
per email, gestisce gli eventi e sollecita le presenze, ha un profilo ridotto
(dati personali e foto, senza stagione e badge), compare in Rosa come «Allenatore»
e non vede badge né cacche. Solo documentazione, nessuna implementazione.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:23:12 +02:00
davideandClaude Opus 5.5 e1e4ca0df6 Semplifica Gestione eventi: solo calendario scorrevole e giorno già fissato nel form
Tolta la lista "Eventi in calendario", che ripeteva il calendario senza azioni proprie:
resta la griglia mensile, che ora si scorre anche con lo swipe come /calendario.
Caricamento ed errore compaiono dentro la card del calendario.

Nel form il giorno toccato sul calendario è mostrato come testo fisso e resta da
scegliere solo l'ora; il campo data compare con «Cambia», per spostare l'evento.

Documentazione: DD-033 (Gestione eventi) e DD-032 (stagione CSI ricavata dalle date),
indice delle decisioni completato con DD-030 e DD-031, rimandi dai moduli.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:10:11 +02:00
davideandClaude Opus 5.5 ae5d790011 Mostra la stagione CSI nell'header di Campionato e divide lo storico per stagione
Il portale CSI non espone il nome della stagione: stagioneDi() la ricava dalla data
della gara (l'anno cambia ad agosto), stagioneDaPartite() dà quella del campionato e
raggruppaPerStagione() divide lo storico partite. Coperte da test in csi-core.test.ts.

Documentato che oggi Campionato mostra 2025/26 mentre Squadra e Calendario mostrano
2026/27: il CSI non ha ancora creato le pagine della nuova stagione.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 14:01:55 +02:00
davideandClaude Opus 5.5 33ed2398ab Sposta il contatore delle notifiche in un pallino sull'avatar del profilo
Tolta la campanella in alto a destra: il numero delle non lette è ora un pallino
rosso sull'angolo dell'avatar. Il tap sull'avatar porta sempre al profilo, quello
sul pallino apre l'elenco delle notifiche (lettura ed eliminazione). Con tutte le
notifiche lette il pallino resta neutro col totale, così restano eliminabili.
Logica del pallino in pallinoNotifiche(), coperta da test; documentazione allineata.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 13:53:58 +02:00
davideandClaude Opus 5.5 c6437a605c Aggiunge il controllo dei tipi e copre con un test lo script di backup
- npm run typecheck (tsc --noEmit) entra nella checklist di fine lavoro:
  bun esegue i test senza controllare i tipi, ed è così che si erano
  accumulati errori di tipo con la suite verde.
- La logica pura del download dello storage passa in storage-core.ts,
  testata con dati finti (nessuna lettura del .env reale).
- Il download dello storage esce con errore se anche un solo file non
  si scarica, così backup.sh non dichiara completo un backup parziale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:25:35 +02:00
davideandClaude Opus 5.5 431530af2a Azzera errori di lint e di tipo e corregge il test sugli slot della rosa
- schema-profili: il test pretendeva che lo slot g1 fosse libero, ma in
  produzione è collegato a un giocatore vero; ora verifica che il
  tentativo anonimo non cambi il valore.
- rosa: il default dei voti MVP era fuori da useMemo e faceva ricalcolare
  la rosa a ogni render finché i voti non erano caricati.
- test dei badge, profili-core, push-sw, scout-stato: tipi allineati al
  codice (MappaPresenze, nascita da M18, renotify, narrowing di deepEqual).
- vite.config: tipizzato come UserConfig e rimosso esbuild.keepNames,
  ignorato da Vite 8 che non usa più esbuild.
- formattazione Prettier in BarraSottosezioni e classifica.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:14:37 +02:00
davideandClaude Opus 5.5 61d4dacb3a Aggiunge lo script di backup completo per il disaster recovery
Scarica schema e dati del database (public e auth) e i file dei bucket
storage, così si può ripartire se si perdono Supabase o Vercel. I dati
generati restano fuori da git perché contengono dati personali reali.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:01:55 +02:00
davideandClaude Sonnet 5 cdd433cb15 Copre con un test il backfill di M19 (nascita dei profili preesistenti
M19 era stato applicato in produzione senza copertura automatica, solo
verificato a mano. Il test simula un profilo compilato prima di M18 ed
esegue lo stesso SQL del backfill via psql, perché PostgREST non espone
un update multi-tabella come RPC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 13:22:19 +02:00
davideandClaude Sonnet 5 3f2ca30775 Rende pubblica a tutta la squadra la data di nascita inserita dal Profilo
Prima veniva mostrata solo per i 17 giocatori del seed storico: chi la
inseriva da sé restava con "Invalid Date" in Squadra, perché il dato
finiva solo in profili_giocatore (privato). Un trigger ora lo sincronizza
in giocatori_squadra.nascita, colonna pubblica a tutta la rosa.

Include il backfill dei profili già compilati prima di M18 (M19),
mancante nel push iniziale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 12:18:01 +02:00
davideandClaude Mythos 4d036f532d Aggiunge il centro notifiche in-app accanto al profilo
Icona a campana con badge delle non lette, indipendente dal canale push:
copre messaggio libero admin, promemoria evento (24h/3h prima), turno
palloni e sollecito presenze. Ogni notifica si rimuove con uno swipe o
la ×, non c'è pulizia automatica delle vecchie (DD-030).

Migration m17_notifiche_utente non ancora applicata in produzione.

Co-Authored-By: Claude Mythos <noreply@anthropic.com>
2026-09-15 11:19:07 +02:00
davideandClaude Sonnet 5 0314560885 Sostituisce la lista di Gestione eventi con un calendario mensile
In /eventi, sopra la lista cronologica c'è ora una griglia mensile
(condivisa con /calendario tramite le nuove funzioni pure di
src/lib/calendario.ts): ogni giorno è cliccabile e apre un drawer con
gli eventi di quel giorno, da cui si crea, modifica o elimina un
evento. Ogni azione resta raggiungibile in un solo modo: rimosso il
bottone "Nuovo evento" e le icone matita/cestino della lista sotto,
che ora si limita a mostrare gli eventi e ad aprire lo stesso drawer
del giorno al click su una riga.

Il campo "Luogo" di un nuovo evento parte vuoto invece che precompilato
con "Palestra Comunale".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 08:57:24 +02:00
davideandClaude Sonnet 5 0286961e92 Aggiunge la possibilità di inviare notifiche push personalizzate dall'admin
Nella tab Notifiche della dashboard admin è ora possibile mostrare anche i
giocatori senza notifiche attive e inviare un messaggio libero a tutta la
squadra o a un singolo giocatore, tramite un nuovo endpoint
/api/public/notifica-personalizzata che riusa lo stesso pattern di invio
delle altre route push admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 08:05:15 +02:00
davideandClaude Sonnet 5 6372ef9c8d Aggiunge la conferma di modifica in Gestione eventi
Prima di salvare l'aggiornamento di un evento esistente, mostra un
drawer di conferma coerente con quello già usato per l'eliminazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:50:54 +02:00
92 changed files with 5835 additions and 1015 deletions

No files matched your search

+9 -1
View File
@@ -40,4 +40,12 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
supabase/.temp/
# Backup locali del database (contengono dati reali degli utenti):
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
+2 -1
View File
@@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
@@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro.
Prima di dire che hai finito:
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
+31 -9
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 09/09/2026
Ultimo aggiornamento: 27/09/2026
## Stato generale
@@ -10,9 +10,10 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0
con il ruolo allenatore, è pronta ma non ancora pubblicata (vedi `docs/CHANGELOG.md`).
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
---
@@ -23,8 +24,8 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
(09/09/2026)
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -42,6 +43,27 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca
schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche,
route aggiornate) non è ancora deployato: la tabella si riempie già in background
- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna
`giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge
l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
in `npx supabase migration list` del 24/09/2026)
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
da `/admin` dopo il deploy del codice nuovo
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
@@ -156,9 +178,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione
1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
---
+1
View File
@@ -34,6 +34,7 @@ npm run dev # http://localhost:8080
```bash
npm run build # build di produzione
npm run lint # eslint (include prettier)
npm run typecheck # controllo dei tipi TypeScript
npm run test # test unit; npm run test:all per la suite completa
```
+111
View File
@@ -0,0 +1,111 @@
# Backup di disaster recovery
Script per scaricare su questo host un backup completo del progetto Supabase:
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
se il progetto Supabase (o Vercel) venisse perso.
## Preparazione, passo per passo
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
`./backups/backup.sh`).
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
```bash
npx supabase --version
```
2. **Accedi con il tuo account Supabase**:
```bash
npx supabase login
```
Si apre il browser per l'OAuth; il token resta salvato in locale.
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
`supabase/config.toml`, quindi in genere basta):
```bash
npx supabase link --project-ref <project_id>
```
Verifica che sia andato a buon fine con `npx supabase projects list`
(il progetto deve comparire con `"linked":true`).
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
```
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
```
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
Project Settings → API. La **service role key** è quella con permessi pieni
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
esposta lato client né committata.
5. **(Solo se userai `--encrypt`) Verifica che gpg sia installato**:
```bash
gpg --version
```
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
## Uso
```bash
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/ con tutto dentro
./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico
# backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase) e cancella la cartella in chiaro
```
Ogni esecuzione crea una cartella nuova con timestamp: non sovrascrive backup
precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano
quando non servono più.
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
incompleto non va considerato valido, rilancialo.
Il backup contiene:
| File/cartella | Contenuto |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
| `data_public.sql` | dati delle tabelle applicative |
| `schema_auth.sql` | schema utenti Supabase Auth |
| `data_auth.sql` | utenti reali, incluso l'hash della password |
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
## Ripristino
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
contenuto di `storage/`.
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
backup — vedi sotto).
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
GitHub e Gitea).
Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`.
## Cosa NON è incluso
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
- **Codice applicativo**: vive nel repo git, non nel database.
## Sicurezza
Questi backup contengono password hash, documenti d'identità e altri dati
personali reali. Per questo:
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
quelli sì restano in git) — **non committare mai i dati generati**;
- se sposti un backup fuori da questo host, usa sempre `--encrypt` o cifra tu
l'archivio a mano;
- cancella i backup in chiaro quando non ti servono più.
## File di questa cartella
| File | Ruolo |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
# Uso:
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto
# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase)
#
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
# in questo repo) e avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAMP="$(date +%Y%m%d_%H%M)"
RUN_DIR="$SCRIPT_DIR/$STAMP"
mkdir -p "$RUN_DIR"
echo "==> Backup in $RUN_DIR"
echo "==> Dump schema pubblico"
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
echo "==> Dump dati pubblici"
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
echo "==> Dump schema auth"
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
echo "==> Dump dati auth (utenti)"
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
echo "==> Download file storage (bucket)"
node "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
echo "==> Backup completato: $RUN_DIR"
if [[ "${1:-}" == "--encrypt" ]]; then
ARCHIVE="$RUN_DIR.tar.gz.gpg"
echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)"
tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE"
rm -rf "$RUN_DIR"
echo "==> Archivio cifrato pronto: $ARCHIVE"
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
fi
+45
View File
@@ -0,0 +1,45 @@
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
// Uso: node backups/download-storage.mjs [cartella-output]
import { createClient } from "@supabase/supabase-js";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { elencaFile, leggiEnv } from "./storage-core.ts";
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const elencaCartella = (bucket) => async (prefisso) => {
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
if (error) throw error;
return data;
};
const falliti = [];
for (const bucket of BUCKETS) {
console.log(`Bucket: ${bucket}`);
const files = await elencaFile(elencaCartella(bucket));
console.log(` ${files.length} file trovati`);
for (const path of files) {
const { data, error } = await supabase.storage.from(bucket).download(path);
if (error) {
console.error(` ERRORE ${path}:`, error.message);
falliti.push(`${bucket}/${path}`);
continue;
}
const destPath = join(OUT_DIR, bucket, path);
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
console.log(` scaricato: ${bucket}/${path}`);
}
}
if (falliti.length) {
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
process.exit(1);
}
console.log("Fatto.");
+30
View File
@@ -0,0 +1,30 @@
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
export function leggiEnv(testo: string): Record<string, string> {
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
if (!match) continue;
const [, chiave, grezzo] = match as unknown as [string, string, string];
const tra = /^(["'])(.*)\1$/.exec(grezzo);
env[chiave] = tra ? tra[2]! : grezzo;
}
return env;
}
export type Voce = { name: string; id: string | null };
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
export async function elencaFile(
lista: (prefisso: string) => Promise<Voce[]>,
prefisso = "",
): Promise<string[]> {
const file: string[] = [];
for (const voce of await lista(prefisso)) {
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
else file.push(percorso);
}
return file;
}
+1
View File
@@ -108,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
+60 -3
View File
@@ -6,12 +6,69 @@ e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elen
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
all'indietro.
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.1.0] - 2026-09-27
### Aggiunto
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
nessuna push (DD-035).
## [1.0.0] - 2026-09-24
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
### Aggiunto
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
`m17_notifiche_utente` (DD-030).
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
notifiche attive.
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
di un evento esistente compare una conferma, come per l'eliminazione.
- **Backup completo** — uno script scarica schema e dati del database e i file dello
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
### Modificato
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
mensile, che si scorre anche con lo swipe come quello della squadra.
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
### Corretto
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
## [0.9.2] - 2026-09-10
### Aggiunto
+33 -30
View File
@@ -7,31 +7,33 @@ non in questo file.
## Permessi di scrittura
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura**
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
| `user_roles` | solo admin |
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## Anagrafica e utenti
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
@@ -44,12 +46,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
@@ -61,19 +63,20 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
## Votazioni
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| Tabella | Scopo | Note |
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
## Funzioni speciali
+384 -29
View File
@@ -16,35 +16,41 @@ Serve a rispondere a domande del tipo:
**Accettate**
| ID | Titolo |
| --------------------------------------------------------------------------------- | ------------------------------------- |
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion |
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
| ID | Titolo |
| ------------------------------------------------------------------------------------------------ | ------------------------------------- |
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion |
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
| [DD-030](#dd-030--centro-notifiche-in-app-storico-separato-dalla-push-letto-solo-con-rls) | Centro notifiche in-app |
| [DD-031](#dd-031--data-di-nascita-pubblica-una-colonna-sincronizzata-non-una-seconda-rls-aperta) | Nascita pubblica sincronizzata |
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
**In valutazione**
@@ -1155,3 +1161,352 @@ di orfani.
**Riesame**
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
aggiornata a mano, non c'è un meccanismo che lo forzi).
### DD-030 — Centro notifiche in-app: storico separato dalla push, letto solo con RLS
**Data:** 15 settembre 2026
**Stato:** Accettata
**Contesto**
Le notifiche esistenti (`docs/modules/notifiche.md`) sono solo push OS/browser: arrivano se
il dispositivo ha attivato l'interruttore in Profilo, sono effimere (nessuno storico) e non
hanno uno stato letto/non letto — chi non attiva le push, o cambia dispositivo, semplicemente
non le vede mai. Serviva un canale visibile sempre, dentro l'app, con un badge accanto al
profilo che segnali quante cose non lette ci sono.
**Decisione**
Nuova tabella `notifiche_utente` (migration `m17_notifiche_utente`), popolata da quattro
sorgenti lato database — mai dal client:
1. messaggio libero dell'admin (`notifica-personalizzata.ts`, in parallelo alla push);
2. promemoria automatico prima di un evento, 24h e 3h prima, due `cron.schedule`
(estensione `pg_cron`, abilitata dal 31/07/2026 e usata per la prima volta qui);
3. turno palloni (`promemoria-palloni.ts`), in parallelo alla push;
4. sollecito presenze (`sollecita-presenze.ts`), in parallelo alla push.
Lettura e scrittura (segna come letta, elimina) passano dal client Supabase autenticato con
RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in
`src/lib/notifiche-utente.ts`), senza una route API dedicata — stesso pattern di
`useSalvaEvento()` in `eventi.ts`. Nessuna policy INSERT per `authenticated`: le righe nascono
solo da funzioni `SECURITY DEFINER` o dalla service role, mai da un client.
Pallino con il conteggio sull'avatar del profilo (`PallinoNotifiche`,
`src/components/crapp/ui-bits.tsx`; in origine una campanella separata, tolta per liberare
l'header: il tap sull'avatar resta l'accesso al profilo, quello sul pallino apre il
pannello), pannello a comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina
con uno swipe o una ×: non c'è pulizia automatica delle notifiche vecchie, quindi è l'unico
modo per un giocatore di toglierle di torno per sempre.
**Alternative scartate**
- Notificare anche sulla sola creazione di un evento, non solo sull'avvicinarsi della data →
scartata su richiesta esplicita: conta quando l'evento sta per succedere, non il momento in
cui è stato messo in calendario. La prima versione aveva un trigger `AFTER INSERT ON
eventi_app` per questo, rimosso prima del rilascio.
- Una route API dedicata per leggere/segnare come lette (come le route push esistenti) →
scartata: qui non serve la service role (niente da nascondere al giocatore stesso), quindi
la RLS basta ed evita di duplicare la logica di lettura già scritta lato client per gli
eventi.
- Pulizia automatica delle notifiche vecchie con un cron di retention (es. elimina le lette
dopo 30 giorni) → scartata per ora a favore dello swipe manuale: più diretto da capire per
chi usa l'app, e la tabella resta comunque piccola per una sola squadra. Resta un'estensione
naturale se in futuro servisse.
- Semplice `INSERT` invece di `upsert` per turno palloni e sollecito presenze → scartata: un
admin che preme due volte lo stesso pulsante per lo stesso evento avrebbe urtato contro il
vincolo `UNIQUE (giocatore_id, evento_id, tipo)`. L'upsert aggiorna la riga esistente
(testo e `creato_il` freschi, `letta` di nuovo `false`), così ripremere il pulsante si
comporta come un rinvio vero, non un errore silenzioso.
**Conseguenze**
- Il pallino sull'avatar riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza
push attive vede comunque tutto, perché il canale è del tutto indipendente.
- `eventi_app.ora` è testo libero e non validato dal form (`<input type="time">` si può
svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe
l'errore di cast riga per riga invece di far fallire l'intera generazione dei promemoria per
tutti gli eventi quando uno solo ha l'ora scritta male.
- `test/integration/notifiche-utente.test.ts` è la definizione eseguibile del comportamento:
promemoria 24h/3h e deduplica, tolleranza a un'ora malformata, RLS su lettura/segna
letta/elimina, e le tre route (`notifica-personalizzata`, `promemoria-palloni`,
`sollecita-presenze`) che scrivono lo storico in-app.
**Riesame**
Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non
fanno mai swipe), va aggiunta la pulizia automatica scartata sopra.
### DD-031 — Data di nascita pubblica: una colonna sincronizzata, non una seconda RLS aperta
**Data:** 15 settembre 2026
**Stato:** Accettata
**Contesto**
In "Squadra" la data di nascita mostrava **"Invalid Date"** per chi l'aveva inserita da
solo dal proprio Profilo. Causa: due fonti mai collegate. I 17 giocatori del seed storico
hanno la nascita in `nascitaPerId`, una mappa statica pubblica bundlata nel client
(`src/lib/crapp-data.ts`) — il commento sulla riga la descriveva esplicitamente come
stopgap: _"`giocatori_squadra` non ha ancora questa colonna (DD-015 follow-up)"_. Un
giocatore aggiunto dopo la migrazione DD-015 inserisce invece la nascita dal form Profilo,
che scrive su `profili_giocatore.data_nascita` — tabella con RLS "solo il proprio profilo o
admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` (`src/lib/rosa.ts`), e comunque
non visibile ai compagni per via della RLS. Per quel giocatore `nascitaPerId[g.id]` era
`undefined` → `""` → `formatData("")` in `src/routes/squadra.tsx` produceva `Invalid Date`.
**Decisione**
Migration `m18_nascita_pubblica_giocatori_squadra`: nuova colonna `giocatori_squadra.nascita`
(quella prevista da DD-015), pubblica a tutta la rosa attiva con la stessa policy di lettura
già in vigore per le altre colonne. Un trigger `SECURITY DEFINER` su `profili_giocatore`
(`sincronizza_nascita_pubblica`, AFTER INSERT/UPDATE/DELETE) la tiene allineata a
`data_nascita`: un solo input nel form Profilo (`CampiProfilo`), due destinazioni — quella
amministrativa esistente (RLS invariata: sé stesso o admin) e questo specchio pubblico.
`profili_giocatore` resta l'unica tabella che il client scrive per la nascita; il client non
tocca mai `giocatori_squadra.nascita` direttamente.
Essendo `SECURITY DEFINER` di proprietà dell'owner della tabella, la scrittura del trigger
bypassa la RLS di `giocatori_squadra` automaticamente — nessuna nuova policy lì. Serve però
un nuovo ramo in `enforce_giocatori_squadra_update()` (il trigger `BEFORE UPDATE` che oggi
rifiuta qualunque modifica a uno slot già collegato da parte di chi non è admin): accetta un
update dove cambia **solo** `nascita` e il chiamante è il proprietario dello slot
(`OLD.auth_user_id = auth.uid()`). Senza quel ramo l'update annidato del trigger di sync
fallirebbe con l'eccezione già in vigore.
Backfill una tantum dei 17 storici con gli stessi valori di `nascitaPerId`, poi rimozione di
quell'export (`crapp-data.ts`): era descritto fin dall'inizio come temporaneo, in attesa
esattamente di questa colonna.
**Alternative scartate**
- Allentare la RLS di `profili_giocatore` per rendere `data_nascita` leggibile a tutta la
squadra → scartata: quella tabella porta anche indirizzo, telefono, documento d'identità
nello stesso record — aprirne la lettura avrebbe esposto molto più della sola nascita.
- Un secondo campo "data di nascita pubblica" nel form Profilo, distinto da quello
amministrativo → scartata: due campi per lo stesso dato reale avrebbero richiesto
spiegare all'utente perché ne esistono due, con rischio di disallineamento se ne compila
uno solo.
- Far scrivere `giocatori_squadra.nascita` direttamente dal client (nuova RLS policy per il
proprietario dello slot) invece di sincronizzarla da `profili_giocatore` → scartata: la
RLS di update per un non-admin resta `USING (auth_user_id IS NULL)` (solo reclamare uno
slot libero); aprirla anche a righe già collegate avrebbe ampliato la superficie scrivibile
dal client su una tabella con un trigger di sicurezza già delicato (DD-016/DD-023), per un
guadagno che il trigger di sync ottiene comunque senza toccare la RLS.
**Conseguenze**
- Chiunque compili la propria data di nascita dal Profilo la vede comparire in Squadra (e
nei compleanni del Calendario) esattamente come i 17 storici — non più un dato "di serie
B".
- Cancellare il profilo (da sé o da un admin) azzera anche la nascita pubblica: non resta un
valore pubblico orfano di un profilo che non esiste più.
- Un giocatore senza nascita nota (profilo mai compilato) non genera più "Invalid Date":
`squadra.tsx` mostra la riga della data di nascita solo se `g.nascita` è valorizzata,
stesso criterio già usato da `compleanniEventi()` per escludere chi non ha ancora un dato.
- `test/integration/nascita-pubblica.test.ts` è la definizione eseguibile del comportamento:
sync alla scrittura, visibilità a un giocatore terzo (non solo l'interessato o l'admin),
azzeramento alla cancellazione, blocco della scrittura diretta dal client.
**Riesame**
Se in futuro serve differenziare "chi può vedere la nascita" (es. solo squadra, non
pubblico) va rivista insieme alla policy di lettura generale di `giocatori_squadra`, non solo
per questa colonna.
**Aggiornamento (15 settembre 2026, stesso giorno)** — dopo aver applicato M18 in produzione
(`supabase db push`), la nascita in Squadra compariva solo per i 17 giocatori storici, non
per chi aveva già compilato il proprio profilo prima della migration: il trigger di sync
copre solo le scritture _future_ su `profili_giocatore`, non retroattivamente, e M18
backfillava a mano solo il seed storico. Migration `m19_backfill_nascita_da_profili_esistenti`
allinea una tantum `giocatori_squadra.nascita` a `profili_giocatore.data_nascita` per tutti i
profili già esistenti, con la stessa disattivazione temporanea del trigger di sicurezza usata
in M18. Da qui in poi il gap non si può ripresentare: ogni scrittura successiva passa dal
trigger di sync, questo backfill serve solo per lo storico antecedente a M18.
A differenza della bonifica eventi orfani (DD-029, M15→M16), qui il backfill non è stato
reso una funzione richiamabile: resta SQL diretto nella migration. La copertura di test
(`test/integration/backfill-nascita-esistenti.test.ts`) verifica comunque lo stesso
comportamento eseguendo lo stesso pattern SQL via `psql` nel container Postgres locale —
scelta necessaria perché senza una funzione RPC, PostgREST non espone un update
multi-tabella come questo.
### DD-032 — La stagione CSI si ricava dalle date delle gare
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
La pagina Campionato (`/classifica`) non diceva a quale stagione si riferissero classifica e
storico partite. Il portale CSI non espone il nome della stagione da nessuna parte:
`project-sheets.php`, `league_details.php`, `team-main.php` e il feed
`getEventsByTeamId.php` riportano solo il nome della competizione (verificato il
24/09/2026). Il `project_id` cambia a ogni stagione ed è aggiornato a mano in
`csi-core.ts`.
**Decisione**
`stagioneDi()` in `src/lib/csi-core.ts` ricava la stagione ("2025/26") dalla data di una
gara, con l'anno che cambia ad agosto; `stagioneDaPartite()` usa la prima gara del
campionato letto (tutte le gare, non solo quelle giocate, così a inizio stagione basta il
calendario). L'header di `/classifica` mostra quella stagione, e lo storico partite è
diviso per stagione con `raggruppaPerStagione()`: le gare dello Scout Live locale, usate
quando il CSI non ne ha, possono essere di stagioni diverse.
**Alternative scartate**
- Una costante `CSI_STAGIONE` accanto a `CSI_PROJECT_ID` → un secondo valore da ricordarsi
di aggiornare a ogni stagione, che può divergere dal `project_id`; ricavata dai dati
segue da sola.
- Usare la stagione "in corso" per la squadra (la data di oggi) → sbaglierebbe proprio nel
periodo di passaggio: a settembre 2026 il CSI mostra ancora i dati 2025/26.
**Conseguenze**
- Oggi Campionato mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la
stagione scritta nel codice, mostrano «Stagione 2026/27»: il CSI non ha ancora creato le
pagine della nuova stagione. È voluto — Campionato riporta la stagione dei dati che
mostra (dettagli in [collegamento-csi.md](modules/collegamento-csi.md)).
- Quando si aggiornano i `project_id` l'etichetta si aggiorna senza toccare altro codice.
**Riesame**
Se il CSI iniziasse a esporre il nome della stagione, leggerlo da lì; se una stagione CSI
dovesse iniziare prima di agosto, spostare la soglia in `stagioneDi()`.
### DD-033 — Gestione eventi: solo calendario, giorno fissato dal tocco
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
`/eventi` mostrava sotto la griglia mensile anche la lista cronologica di tutti gli eventi
("Eventi in calendario"). La lista non aveva azioni proprie (toccare una riga apriva lo
stesso drawer del giorno) e allungava la pagina con informazioni già nel calendario. Nel
form, poi, il campo «Data» era già precompilato con il giorno toccato ma sembrava da
compilare: l'amministratore doveva scegliere solo l'ora.
**Decisione**
La pagina mostra solo il calendario, che si scorre di mese in mese con le frecce o con lo
swipe (stessa fisica a molla di `/calendario`, disattivata con `useMotoRidotto()`). Nel form
il giorno compare come testo fisso e resta da scegliere l'ora; il campo data compare solo
toccando «Cambia», per spostare un evento in un altro giorno.
**Alternative scartate**
- Togliere del tutto il campo data → non si potrebbe più spostare un evento esistente, una
funzione che oggi c'è (regola anti-regressione).
- Tenere la lista ma più corta (solo i prossimi eventi) → i prossimi eventi sono già in home
e in `/calendario`; qui sarebbe stata un doppione.
**Conseguenze**
- Per trovare un evento lontano bisogna scorrere fino al suo mese; il totale degli eventi
resta nel sottotitolo dell'header.
- Caricamento ed errore di `useEventi()` compaiono dentro la card del calendario, dove prima
stava la lista.
**Riesame**
Se gli eventi crescono tanto da rendere scomodo lo scorrere dei mesi, valutare una ricerca
per titolo invece di reintrodurre la lista completa.
### DD-034 — L'allenatore è uno slot della squadra con tipo diverso, non un giocatore
**Data:** 24 settembre 2026
**Stato:** Accettata (implementata con le migration M20 e M21)
**Contesto**
Serve un account per l'allenatore: deve gestire gli eventi (oggi solo admin, `m11`) ma non è
un giocatore. Non ha statistiche, badge né stagione, non vota e non viene votato. L'admin
vuole registrarlo come i giocatori, inserendo la sua email Gmail. Specifica completa in
[modules/allenatore.md](modules/allenatore.md).
**Decisione**
L'allenatore è una riga di `giocatori_squadra` con una nuova colonna `tipo = 'allenatore'`
(default `'giocatore'`). Così riusa senza cambiarli il collegamento per email (DD-018),
`profili_giocatore` per i dati personali, il bucket avatar e il compleanno sincronizzato
(DD-031). Il permesso di gestire gli eventi arriva da un nuovo valore `allenatore` di
`app_role` in `user_roles` (DD-011), scritto da una funzione `SECURITY DEFINER` quando si
collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando lo slot.
"Rosa" diventa `attivo e tipo = 'giocatore'` ovunque.
**Alternative scartate**
- Una tabella `staff` separata → isola l'allenatore da voti e rosa senza toccare i filtri, ma
duplica collegamento per email, profilo, avatar e form admin. Resta l'alternativa se i
filtri sulla rosa si rivelano troppi o fragili.
- Registrare l'allenatore come admin → gli darebbe dati personali e documenti di tutta la
squadra (DD-017), molto più del necessario.
- Distinguere l'allenatore dal campo `ruolo` (ruolo in campo) → mescola due concetti e il
campo è testo libero.
**Conseguenze**
- Ogni lettura della rosa (client, server, SQL di voto e promemoria) va filtrata per `tipo`:
un punto dimenticato rende l'allenatore votabile o lo conta tra i convocati. L'elenco sta
nella specifica e va coperto dai test di `permessi.test.ts`.
- Il trigger sugli slot cambia: `tipo` lo scrive solo l'admin; nome e cognome li scrive anche
l'allenatore sul proprio slot.
- `numero` diventa nullabile a database, ma solo per gli allenatori (vincolo di M21); nell'app
resta un numero, 0 per l'allenatore, per non toccare i tipi di tutta la rosa.
- Due fonti distinte, ciascuna per il suo scopo: i permessi (eventi, sollecito) da
`user_roles`, la visibilità (badge, cacche, stagione) dal tipo del proprio slot, che è
immediato e non aspetta la query dei ruoli.
- L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra.
- In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`;
riceve i promemoria degli eventi e può sollecitare le presenze, non il turno palloni.
- Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata.
**Riesame**
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
fonte di bug, passare alla tabella `staff` separata.
### DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
**Data:** 24 settembre 2026
**Stato:** Accettata (implementata, versione 1.1.0)
**Contesto**
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
certificato viene rinnovato. Specifica completa in
[modules/profilo-giocatore.md](modules/profilo-giocatore.md#avviso-certificati).
**Decisione**
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
riferito solo a sé. L'allenatore e i compagni non lo vedono.
3. L'avviso dello staff **non è cliccabile**; quello personale apre
`/profilo?tab=documenti`.
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
ancora (come `statoScadenza()`).
5. **Niente push**: l'avviso vive solo in Home.
**Alternative scartate**
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
che all'allenatore non serve ad agire.
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
è gratis e sempre giusto.
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
insieme ed è proprio lì che l'elenco completo serve.
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
dal widget «Completa il tuo profilo» e dalla tab Profili.
**Conseguenze**
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
da solo.
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
**Riesame**
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
+9
View File
@@ -27,6 +27,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
## Backup e disaster recovery
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
Uso, contenuto del backup e procedura di ripristino sono descritti in
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
variabili d'ambiente Vercel (richiede login interattivo).
## Migrazione su server proprio (sintesi)
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
+10 -10
View File
@@ -6,16 +6,16 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
## Dove sta cosa
| Documento | Risponde a |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
| Documento | Risponde a |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
+9 -1
View File
@@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q
## Fatto
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
@@ -21,6 +21,9 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
- [x] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del
profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
sollecito presenze)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
@@ -33,6 +36,11 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
## Prossimo
+192
View File
@@ -0,0 +1,192 @@
# Modulo — Ruolo Allenatore
**Stato:** implementato
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
`test/integration/permessi-route.test.ts`
## Obiettivo
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
riceve voti e non ha statistiche, badge o stagione.
## Registrazione
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
3. Entra in Home.
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
## Profilo
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
| Tab | Giocatore | Allenatore |
| ------------ | --------- | ---------------------------------------------- |
| Stagione | sì | **no** |
| Badge | sì | **no** |
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
### Dati modificabili dall'allenatore
| Campo | Dove sta oggi | Nota |
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
| Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto
tessera, tesseramento CSI.
### Completamento profilo
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
quando quei campi sono compilati.
## Home
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
vedono solo gli admin e il giocatore interessato (DD-035).
## Squadra
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
statistiche, e non compare nella tab Stats né nelle classifiche.
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
non compaiono.
| Tab | Giocatore | Allenatore |
| --------- | --------- | ---------- |
| Rosa | sì | sì |
| Stats | sì | sì |
| Obiettivi | sì | **no** |
| Badge | sì | **no** |
## Badge e cacche nascosti
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
Presenze, media voto, MVP e palloni restano visibili.
## Gestione eventi
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
risposte presenze altrui, gestione dei ruoli.
## Notifiche
L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche
in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()`
lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio
Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che
riguardano chi gioca.
## Esclusione dalla rosa
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
(`useAnagraficaRosa({ conAllenatori: true })`), i destinatari dei promemoria evento e dei
messaggi admin, che lo includono.
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial` |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
giocatore).
## Permessi (riepilogo)
| Azione | Giocatore | Allenatore | Admin |
| ----------------------------------------------- | ---------- | ------------------ | ------------------ |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato, foto tessera | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
## Implementazione
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
lascia usare un valore di enum nella transazione che lo crea.
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti.
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
## Scelte confermate
- Riceve i promemoria degli eventi: sì.
- Sollecita le presenze: sì. Promemoria palloni: no.
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
posto del ruolo.
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
gli autenticati.
+21 -9
View File
@@ -1,10 +1,11 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
`src/components/crapp/EventoCard.tsx` (card condivisa)
**Test:** `test/unit/eventi.test.ts`
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
---
@@ -19,14 +20,25 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
+14 -1
View File
@@ -43,6 +43,15 @@ un `project_id`), nessun codice le chiama direttamente.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
@@ -155,7 +164,8 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili)
chevron di dettaglio sulle gare cliccabili, gare divise
per stagione; stagione anche nel sottotitolo dell'header)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
@@ -239,6 +249,9 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
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
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
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`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
+70 -8
View File
@@ -1,9 +1,11 @@
# Modulo — Notifiche
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
---
@@ -16,6 +18,11 @@ indipendenti:
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
---
## Dati
@@ -81,14 +88,24 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
| Route | Controllo | Chi la chiama |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
l'ha ancora attivata.
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
---
@@ -102,6 +119,51 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
---
## Centro notifiche in-app (M17)
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Quattro sorgenti scrivono in `notifiche_utente`, mai il client:
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo
`UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda
doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
da `avvisiPalloniEvento()`, in parallelo alla push.
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
Turno palloni e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)`
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
separato.
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
+144 -3
View File
@@ -14,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
## Utenti
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
la specifica sta in [allenatore.md](allenatore.md).
### Giocatore
Può:
@@ -35,7 +38,8 @@ Può:
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
@@ -60,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
## Home
Il giocatore visualizza un widget dedicato.
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
### Completa il tuo profilo
@@ -76,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
sulla tab Stagione.
### Avviso certificati
**Stato:** implementato (DD-035).
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
già salvata: niente tabella, niente migration, niente push.
#### Chi lo vede
| Utente | Cosa vede | Al tocco |
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
| Altri giocatori | niente | — |
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
nuovo, come il widget «Completa il tuo profilo».
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
solo proprio a un giocatore.
#### Quando compare
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
`GIORNI_AVVISO_CERTIFICATO = 7`.
| Condizione | Stato | Avviso |
| -------------------- | ----------- | ------------------ |
| `giorni > 7` | valido | nessuno |
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno |
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
tab Profili di `/admin` e nel widget di completamento del giocatore.
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
cioè `attivo` e `tipo = 'giocatore'`).
#### Testi
| Avviso | Titolo (staff / personale) | Riga |
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
scadono molti insieme.
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
nero, poi quello giallo.
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
#### Aspetto e posizione
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
di confondersi con lo sfondo.
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
ciò che scade oggi, poi ciò che manca.
#### Rinnovo
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
data, chiunque l'abbia scritta.
#### Caricamento
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
la Home non deve rompersi per un dato accessorio.
#### Implementazione
| Pezzo | Ruolo |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili.
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
di anno nel calcolo dei giorni.
## Profilo
Il profilo viene suddiviso in sette aree.
@@ -91,7 +217,11 @@ Il profilo viene suddiviso in sette aree.
**Dati personali** — modificabili dal giocatore.
- Data di nascita
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
anche quel valore pubblico.
- Luogo di nascita
- Indirizzo di residenza
- Telefono
@@ -124,6 +254,10 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file.
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
riceve un avviso nella propria Home e compare in quello degli admin (vedi
[Avviso certificati](#avviso-certificati)).
Lo storico non viene mantenuto nella prima versione.
### Foto tessera
@@ -175,6 +309,9 @@ alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
@@ -244,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
Non carica file al posto di altri.
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
## Versione 1
- Profilo giocatore
@@ -258,6 +398,7 @@ Non carica file al posto di altri.
## Versioni future
- Storico certificati medici
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
- Gestione documenti aggiuntivi
- Consensi privacy
- Firma digitale
+24 -15
View File
@@ -22,7 +22,11 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita
viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`,
DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il
giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null`
finché non è stata inserita da nessuno.
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
@@ -35,19 +39,29 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti.
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
## Allenatori in Squadra
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto |
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
| Azione | Hook | Effetto |
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
@@ -77,12 +91,7 @@ presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squ
## Limiti noti
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
Calendario per quei giocatori.
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "0.9.2",
"version": "1.1.0",
"private": true,
"sideEffects": false,
"type": "module",
@@ -10,6 +10,7 @@
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"test": "bun test/run.ts",
"test:integration": "bun test/run.ts integration",
+113
View File
@@ -0,0 +1,113 @@
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificati,
formatDataBreve,
testoScadenza,
type AvvisoCertificato,
} from "@/lib/profili-core";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { oggiISO } from "@/lib/palloni-core";
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
function Card({
colore,
titolo,
righe,
cliccabile,
}: {
colore: "warning" | "primary";
titolo: string;
righe: string[];
cliccabile?: boolean;
}) {
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning"
? "bg-warning text-warning-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`;
const contenuto = (
<>
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> {titolo}
</p>
{righe.map((r) => (
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
{r}
</p>
))}
</>
);
return cliccabile ? (
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
{contenuto}
</Link>
) : (
<div className={classi}>{contenuto}</div>
);
}
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
export function AvvisoCertificati() {
const admin = useIsAdmin();
const ruoliPronti = useRuoliPronti();
const base = useGiocatoreBase();
const { righe: rosa } = useGiocatoriSquadra();
const { profili, isPending } = useProfili();
if (!ruoliPronti || isPending) return null;
const oggi = oggiISO();
if (admin) {
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
if (scaduti.length === 0 && inScadenza.length === 0) return null;
return (
<>
{scaduti.length > 0 ? (
<Card
colore="primary"
titolo="Certificati scaduti"
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Card
colore="warning"
titolo="Certificati in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
if (!base) return null;
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
const mio = scaduti[0] ?? inScadenza[0];
if (!mio) return null;
return mio.giorni < 0 ? (
<Card
colore="primary"
titolo="Certificato scaduto"
cliccabile
righe={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Card
colore="warning"
titolo="Certificato in scadenza"
cliccabile
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
/>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
}
+3 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion } from "motion/react";
import { motion, type Transition } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
@@ -13,7 +13,7 @@ export type VoceSottosezione = {
};
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
/**
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
@@ -116,11 +116,7 @@ export function BarraSottosezioni({
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza
? "min-w-0 flex-1"
: sottolineatura
? "shrink-0"
: "w-full",
riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
+6 -6
View File
@@ -14,9 +14,9 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
@@ -69,12 +69,12 @@ export function EventoCard({
}) {
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreBase();
const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
+3 -3
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,8 +20,8 @@ export function Pagelle({
convocati: Giocatore[];
chiuse?: boolean;
}) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null);
+143 -2
View File
@@ -12,8 +12,10 @@ import {
useSalvaProfilo,
type SezioneFile,
} from "@/lib/profili";
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
import {
completamento,
completamentoAllenatore,
profiloVuoto,
sezioniComplete,
type Profilo,
@@ -244,6 +246,138 @@ export function CampiProfilo({
);
}
/**
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
*/
export function CampiAllenatore({
corrente,
aggiorna,
}: {
corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void;
}) {
return (
<>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita">
<input
type="date"
value={corrente.dataNascita ?? ""}
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Luogo di nascita">
<input
value={corrente.luogoNascita ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
</>
);
}
/**
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
* ridotti. Un solo pulsante salva entrambi.
*/
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
const { profili } = useProfili();
const salvaProfilo = useSalvaProfilo();
const salvaNome = useSalvaNomeAllenatore();
const [bozza, setBozza] = useState<Profilo | null>(null);
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
const sporco = bozza !== null || nome !== null;
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
const perc = completamentoAllenatore(corrente);
async function salva() {
try {
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
if (bozza) await salvaProfilo.mutateAsync(bozza);
setNome(null);
setBozza(null);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
return (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center justify-between gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
{perc}%
</span>
</div>
<Intestazione titolo="Dati personali" completa={perc === 100} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
value={nomeCorrente.nome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Cognome">
<input
value={nomeCorrente.cognome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
<button
type="button"
onClick={() => void salva()}
disabled={!sporco || inCorso}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
</button>
</div>
);
}
/**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
@@ -380,13 +514,18 @@ export function ProfiloAmministrativo({
*/
export function CompletaProfilo({
giocatoreId,
allenatore = false,
indice = 0,
}: {
giocatoreId: string;
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
allenatore?: boolean;
indice?: number;
}) {
const { profili, isPending } = useProfili();
const perc = completamento(profili[giocatoreId]);
const perc = allenatore
? completamentoAllenatore(profili[giocatoreId])
: completamento(profili[giocatoreId]);
if (isPending || perc === 100) return null;
return (
@@ -409,7 +548,9 @@ export function CompletaProfilo({
/>
</div>
<p className="mt-2 text-[13px] text-muted-foreground">
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
{allenatore
? "Data e luogo di nascita, telefono ed email."
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
</p>
</Link>
</Reveal>
+3 -3
View File
@@ -3,12 +3,12 @@ import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
const io = useGiocatoreInCampo();
const { turni } = useTurniPalloni();
const { eventi } = useEventi();
if (!io) return null;
+8 -6
View File
@@ -8,9 +8,9 @@ import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
@@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const salva = useSalvaPresenza();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const io = useGiocatoreInCampo();
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
@@ -37,7 +39,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const res = await fetch("/api/public/sollecita-presenze", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: io?.nome }),
body: JSON.stringify({ eventoId, da: mittente?.nome }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
@@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
</div>
) : null}
{admin ? (
{puoSollecitare ? (
<button
type="button"
onClick={sollecita}
+3 -3
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import {
@@ -27,8 +27,8 @@ export function SondaggioCacche({
dataEvento: string;
oraEvento: string;
}) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
+2 -2
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
@@ -18,7 +18,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const id = turni[eventoId];
const proposto = !salvati[eventoId];
+5 -5
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi";
import {
@@ -25,14 +25,14 @@ import {
*/
export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id;
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => g.attivo && presente(g.id));
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? [];
+5 -5
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import {
categorieSocial,
conteggioCategoria,
@@ -15,12 +15,12 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const [aperta, setAperta] = useState<string | null>(null);
const tutti = voti.data ?? [];
+173 -9
View File
@@ -1,10 +1,27 @@
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useMotoRidotto } from "@/lib/motion";
import { molla, proietta } from "@/lib/molla";
import {
pallinoNotifiche,
useEliminaNotifica,
useNotificheMie,
useSegnaLette,
type NotificaUtente,
} from "@/lib/notifiche-utente";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -48,6 +65,66 @@ export function Card({
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
}
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
function tempoRelativo(dataIso: string): string {
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
const ore = Math.round(minuti / 60);
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
return rtf.format(Math.round(ore / 24), "day");
}
/**
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
* c'è "indietro", solo "via".
*/
function RigaNotifica({
notifica,
onElimina,
}: {
notifica: NotificaUtente;
onElimina: (id: string) => void;
}) {
const ridotto = useMotoRidotto();
return (
<motion.li
layout={!ridotto}
initial={false}
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
transition={molla.ui}
className="relative overflow-hidden rounded-xl"
>
<motion.div
drag={ridotto ? false : "x"}
dragConstraints={{ left: -80, right: 0 }}
dragElastic={{ left: 0.2, right: 0 }}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) onElimina(notifica.id);
}}
className="touch-pan-y bg-card p-3 pr-8 text-sm"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? <p className="mt-0.5 text-muted-foreground">{notifica.corpo}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
onClick={() => onElimina(notifica.id)}
aria-label="Rimuovi notifica"
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.li>
);
}
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
@@ -57,18 +134,105 @@ export function Card({
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
*
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
* porta sempre al profilo, quello sul pallino apre il pannello.
*/
export function LinkProfilo() {
const g = useGiocatoreBase();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
<div className="relative shrink-0">
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
<PallinoNotifiche />
</div>
);
}
/**
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
* `src/lib/notifiche-utente.ts`.
*
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
*/
function PallinoNotifiche() {
const { notifiche, nonLette } = useNotificheMie();
const segnaLette = useSegnaLette();
const eliminaNotifica = useEliminaNotifica();
const [aperto, setAperto] = useState(false);
const riquadro = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!aperto) return;
const chiudiSeFuori = (e: MouseEvent) => {
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
};
const chiudiConEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setAperto(false);
};
document.addEventListener("mousedown", chiudiSeFuori);
document.addEventListener("keydown", chiudiConEsc);
return () => {
document.removeEventListener("mousedown", chiudiSeFuori);
document.removeEventListener("keydown", chiudiConEsc);
};
}, [aperto]);
const pallino = pallinoNotifiche(notifiche.length, nonLette);
if (!pallino) return null;
function alClick() {
const stavaChiuso = !aperto;
setAperto(stavaChiuso);
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
}
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
// notifica non si riapre da solo.
function elimina(id: string) {
if (notifiche.length === 1) setAperto(false);
eliminaNotifica.mutate(id);
}
return (
<div ref={riquadro}>
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
<button
type="button"
onClick={alClick}
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
aria-expanded={aperto}
className={cn(
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
pallino.daLeggere
? "bg-accent-grad text-accent-foreground"
: "bg-muted text-muted-foreground",
)}
>
{pallino.testo}
</button>
{aperto ? (
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
<ul className="max-h-80 overflow-y-auto">
<AnimatePresence initial={false}>
{notifiche.map((n) => (
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
))}
</AnimatePresence>
</ul>
</div>
) : null}
</div>
);
}
+2 -2
View File
@@ -637,7 +637,7 @@ export type Database = {
};
};
Enums: {
app_role: "admin" | "user";
app_role: "admin" | "user" | "allenatore";
};
CompositeTypes: {
[_ in never]: never;
@@ -762,7 +762,7 @@ export const Constants = {
},
public: {
Enums: {
app_role: ["admin", "user"],
app_role: ["admin", "user", "allenatore"],
},
},
} as const;
+23 -10
View File
@@ -7,9 +7,9 @@
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale.
*
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
* verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
*/
@@ -24,10 +24,14 @@ function tokenDaRichiesta(request: Request): string | null {
}
/**
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
* `403` se il token è buono ma l'utente non è admin.
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
* vale il token, `403` se il token è buono ma il ruolo no.
*/
export async function richiediAdmin(request: Request): Promise<Response | null> {
async function richiediRuolo(
request: Request,
ruoli: Array<"admin" | "allenatore">,
rifiuto: string,
): Promise<Response | null> {
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
@@ -37,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: ruolo } = await supabaseAdmin
const { data: righe } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
.in("role", ruoli);
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
if (!righe?.length) return new Response(rifiuto, { status: 403 });
return null;
}
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
export function richiediAdmin(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
}
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from "react";
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
export const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
export function pad2(n: number) {
return String(n).padStart(2, "0");
}
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
export function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
export function useMeseNav(initial?: { anno: number; mese: number }) {
const oggi = new Date();
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
const [direzione, setDirezione] = useState(0);
const precedente = () => {
setDirezione(-1);
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
setDirezione(1);
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, direzione, precedente, successivo };
}
-8
View File
@@ -122,14 +122,6 @@ export const giocatori: Giocatore[] = rosaCSI
}))
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
/**
* Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna
* (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota.
*/
export const nascitaPerId: Record<string, string> = Object.fromEntries(
giocatori.map((g) => [g.id, g.nascita]),
);
export type RigaClassifica = {
pos: number;
squadra: string;
+37
View File
@@ -21,6 +21,43 @@ export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
/**
* Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non
* espone il nome della stagione (né nella classifica né nel calendario della squadra):
* si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di
* settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono.
*/
export function stagioneDi(dataIso: string): string {
const anno = Number(dataIso.slice(0, 4));
const mese = Number(dataIso.slice(5, 7));
const inizio = mese >= 8 ? anno : anno - 1;
return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`;
}
/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */
export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null {
const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d));
if (date.length === 0) return null;
return stagioneDi(date.reduce((a, b) => (a < b ? a : b)));
}
/**
* Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in
* locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato.
*/
export function raggruppaPerStagione<T extends { data: string }>(
match: readonly T[],
): Array<{ stagione: string; match: T[] }> {
const gruppi: Array<{ stagione: string; match: T[] }> = [];
for (const m of match) {
const stagione = stagioneDi(m.data);
const gruppo = gruppi.find((g) => g.stagione === stagione);
if (gruppo) gruppo.match.push(m);
else gruppi.push({ stagione, match: [m] });
}
return gruppi;
}
export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
export const urlPartite = (teamId = CSI_TEAM_ID) =>
+1 -1
View File
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
id: nuovoIdEvento(),
tipo: "allenamento",
titolo: "",
luogo: "Palestra Comunale",
luogo: "",
data: new Date().toISOString().slice(0, 10),
ora: "20:30",
note: "",
+95 -23
View File
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
* la migrazione non è completa (DD-016 regola 1).
*/
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
export type TipoMembro = "giocatore" | "allenatore";
export type GiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
numero: number;
ruolo: string;
authUserId: string | null;
@@ -18,19 +22,24 @@ export type GiocatoreSquadra = {
email: string | null;
numeroTessera: string | null;
dataTessera: string | null;
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
nascita: string | null;
tipo: TipoMembro;
};
export type RigaGiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
numero: number | null;
ruolo: string;
auth_user_id: string | null;
attivo: boolean;
email: string | null;
numero_tessera: string | null;
data_tessera: string | null;
nascita: string | null;
tipo: TipoMembro;
};
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
@@ -50,6 +59,8 @@ export function rosaFallback(): GiocatoreSquadra[] {
email: null,
numeroTessera: null,
dataTessera: null,
nascita: g.nascita,
tipo: "giocatore" as const,
}));
}
@@ -57,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
return `${g.nome} ${g.cognome}`.trim();
}
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
return g?.tipo === "allenatore";
}
/**
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
*/
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
return g.attivo && g.tipo === "giocatore";
}
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
}
/** Lo slot già collegato a questo account, se esiste. */
export function slotDi(
righe: GiocatoreSquadra[],
@@ -77,7 +106,7 @@ export function slotPerEmail(
}
export const COLONNE_SQUADRA =
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
@@ -85,13 +114,15 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
id: r.id,
nome: r.nome,
cognome: r.cognome,
numero: r.numero,
numero: r.numero ?? 0,
ruolo: r.ruolo,
authUserId: r.auth_user_id,
attivo: r.attivo,
email: r.email,
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
nascita: r.nascita,
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
};
}
@@ -115,7 +146,10 @@ export function useGiocatoriSquadra() {
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
export type DatiSquadra = Pick<
GiocatoreSquadra,
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
>;
/**
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
@@ -125,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
if (dati.tipo === "allenatore") {
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
return null;
}
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
return "Il numero di maglia deve essere maggiore di zero.";
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
@@ -147,7 +186,19 @@ export function numeroGiaUsato(
giocatoreId: string,
numero: number,
): boolean {
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
}
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
function rigaDatiSquadra(dati: DatiSquadra) {
const allenatore = dati.tipo === "allenatore";
return {
nome: dati.nome.trim(),
cognome: dati.cognome.trim(),
numero: allenatore ? null : dati.numero,
ruolo: allenatore ? "" : dati.ruolo.trim(),
email: dati.email?.trim() || null,
};
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
@@ -155,19 +206,13 @@ export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
const dati = {
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const riga = rigaDatiSquadra(input.dati);
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.update(riga)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
@@ -220,18 +265,19 @@ export function useAggiungiGiocatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
const riga = {
id: input.id,
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error;
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null };
return {
...riga,
numero: riga.numero ?? 0,
authUserId: null,
attivo: true,
numeroTessera: null,
dataTessera: null,
nascita: null,
};
},
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
// (`.order("cognome").order("nome")`).
@@ -245,6 +291,32 @@ export function useAggiungiGiocatore() {
});
}
/**
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
*/
export function useSalvaNomeAllenatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
/**
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
+16 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
@@ -20,3 +20,18 @@ export function useNotificheAttive() {
staleTime: 5 * 60_000,
});
}
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
export function useInviaNotifica() {
return useMutation({
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
const res = await fetch("/api/public/notifica-personalizzata", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ messaggio, giocatoreId }),
});
if (!res.ok) throw new Error("Impossibile inviare la notifica");
return (await res.json()) as { inviate: number; destinatari: number };
},
});
}
+145
View File
@@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { useGiocatoreId } from "./user-store";
/**
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
* lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
* parallelo alla push esistente). Qui si legge e si segna come letto, con
* `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
* client-nuove-tabelle.ts).
*/
export type TipoNotifica =
| "admin"
| "evento_promemoria_24h"
| "evento_promemoria_3h"
| "turno_palloni"
| "sollecita_presenze";
export type NotificaUtente = {
id: string;
tipo: TipoNotifica;
titolo: string;
corpo: string;
eventoId: string | null;
letta: boolean;
creataIl: string;
};
export type RigaNotifica = {
id: string;
tipo: TipoNotifica;
titolo: string;
corpo: string;
evento_id: string | null;
letta: boolean;
creato_il: string;
};
const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il";
/** Conversione riga database -> modello applicativo. */
export function daRiga(r: RigaNotifica): NotificaUtente {
return {
id: r.id,
tipo: r.tipo,
titolo: r.titolo,
corpo: r.corpo,
eventoId: r.evento_id,
letta: r.letta,
creataIl: r.creato_il,
};
}
function chiaveNotifiche(giocatoreId: string | null) {
return ["notifiche-utente", giocatoreId] as const;
}
/** Ultime notifiche del giocatore selezionato, più recenti per prime. */
export function useNotificheMie() {
const giocatoreId = useGiocatoreId();
const query = useQuery({
queryKey: chiaveNotifiche(giocatoreId),
queryFn: async (): Promise<NotificaUtente[]> => {
const { data, error } = await supabaseNuoveTabelle
.from("notifiche_utente")
.select(COLONNE)
.eq("giocatore_id", giocatoreId)
.order("creato_il", { ascending: false })
.limit(30);
if (error) throw error;
return ((data ?? []) as RigaNotifica[]).map(daRiga);
},
enabled: !!giocatoreId,
staleTime: 30_000,
refetchInterval: 60_000,
});
const notifiche = query.data ?? [];
return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length };
}
/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */
export function useSegnaLette() {
const giocatoreId = useGiocatoreId();
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!giocatoreId) return;
const { error } = await supabaseNuoveTabelle
.from("notifiche_utente")
.update({ letta: true })
.eq("giocatore_id", giocatoreId)
.eq("letta", false);
if (error) throw error;
},
onSuccess: () => {
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
(prec ?? []).map((n) => ({ ...n, letta: true })),
);
},
});
}
/**
* Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A
* differenza di "segna come letta" qui la riga sparisce anche dal database, non solo
* dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è
* l'unico modo per un giocatore di toglierla di mezzo per sempre.
*/
export function useEliminaNotifica() {
const giocatoreId = useGiocatoreId();
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id);
if (error) throw error;
return id;
},
onSuccess: (id) => {
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
(prec ?? []).filter((n) => n.id !== id),
);
},
});
}
/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */
export function contatoreBadge(nonLette: number): string {
return nonLette > 9 ? "9+" : String(nonLette);
}
/**
* Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette
* resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più
* raggiungibili per eliminarle. Senza notifiche non c'è (`null`).
*/
export function pallinoNotifiche(
totale: number,
nonLette: number,
): { testo: string; daLeggere: boolean } | null {
if (totale === 0) return null;
if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true };
return { testo: contatoreBadge(totale), daLeggere: false };
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
export const TURNI_KEY = ["turni-palloni"] as const;
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
const { eventi } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salvati = query.data ?? {};
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import { useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
import {
presenzeUltimoMese,
presenzeUltimoMeseTutti,
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => {
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
const idRosa = squadra.filter(inRosa).map((g) => g.id);
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
}, [eventi, presenze, squadra]);
}
+5 -3
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live";
@@ -71,17 +72,18 @@ export function totaliEventiGiocatore(
}
/**
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
*/
export function destinatariSollecito(
squadra: Array<{ id: string; attivo: boolean }>,
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
risposte: Array<{ giocatore_id: string; stato: string }>,
): string[] {
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
return squadra
.filter((g) => g.attivo)
.filter(inRosa)
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
+83 -1
View File
@@ -1,5 +1,5 @@
import { rigaCsv } from "./scout-export";
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
/**
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
@@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number {
);
}
/**
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
* certificato né foto tessera, che servono al tesseramento dei giocatori.
*/
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
export function completamentoAllenatore(p: Profilo | null | undefined): number {
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
}
export type StatoScadenza = "mancante" | "scaduto" | "valido";
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
@@ -203,3 +215,73 @@ export function csvTesseramento(
export function etichettaGiocatore(g: GiocatoreSquadra): string {
return `#${g.numero} ${nomeCompleto(g)}`;
}
/** Soglia dell'avviso certificati in Home (DD-035). */
export const GIORNI_AVVISO_CERTIFICATO = 7;
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
const a = new Date(`${scadenza}T00:00:00`);
const b = new Date(`${oggi}T00:00:00`);
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
}
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
export function formatDataBreve(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/** Riga dell'avviso certificati per un giocatore (DD-035). */
export function testoScadenza(giorni: number): string {
if (giorni === 0) return "scade oggi";
if (giorni === 1) return "scade domani";
return `scade tra ${giorni} giorni`;
}
export type AvvisoCertificato = {
giocatoreId: string;
nome: string;
cognome: string;
scadenza: string;
giorni: number;
};
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
}
/**
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
*/
export function avvisiCertificati(
rosa: GiocatoreSquadra[],
profili: Record<string, Profilo>,
oggi: string,
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
const scaduti: AvvisoCertificato[] = [];
const inScadenza: AvvisoCertificato[] = [];
for (const g of rosa) {
if (!inRosa(g)) continue;
const p = profili[g.id];
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
const avviso: AvvisoCertificato = {
giocatoreId: g.id,
nome: g.nome,
cognome: g.cognome,
scadenza: p.certificatoScadenza,
giorni,
};
(giorni < 0 ? scaduti : inScadenza).push(avviso);
}
scaduti.sort(confrontaAvvisi);
inScadenza.sort(confrontaAvvisi);
return { scaduti, inScadenza };
}
+39 -38
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { nascitaPerId, type Giocatore } from "./crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import type { Giocatore } from "./crapp-data";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle";
import { statisticheCacche, useCacche } from "./cacche";
@@ -27,26 +27,29 @@ function iniziali(nome: string, cognome: string): string {
/**
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e
* il relativo `useMemo` solo per l'anagrafica.
* per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
* cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
* solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi
* elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034).
*/
export function useAnagraficaRosa(): Array<
export function useAnagraficaRosa({
conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
> {
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(
() =>
squadra
.filter((g) => g.attivo)
.filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
ruolo: g.ruolo,
numero: g.numero,
nascita: nascitaPerId[g.id] ?? "",
nascita: g.nascita ?? "",
})),
[squadra],
[squadra, conAllenatori],
);
}
@@ -54,7 +57,7 @@ export function useAnagraficaRosa(): Array<
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
* dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva
* rispetto a quelle che l'app fa comunque.
*/
export function useRosa(): Giocatore[] {
@@ -67,7 +70,7 @@ export function useRosa(): Giocatore[] {
const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
const votiMvp = voti.data ?? [];
const votiMvp = voti.data;
return useMemo(() => {
const medie = mediePagelle(pagelle);
@@ -76,34 +79,32 @@ export function useRosa(): Giocatore[] {
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
// scelto per un evento passato senza che nessuno confermasse nulla.
const palloni = conteggioTurni(turniSalvati, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
return squadra
.filter((g) => g.attivo)
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
numero: g.numero,
ruolo: g.ruolo,
nascita: nascitaPerId[g.id] ?? "",
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
return squadra.filter(inRosa).map((g) => ({
id: g.id,
nome: nomeCompleto(g),
numero: g.numero,
ruolo: g.ruolo,
nascita: g.nascita ?? "",
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [
squadra,
votiMvp,
+39 -15
View File
@@ -2,33 +2,57 @@ import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
export const RUOLI_KEY = ["ruoli-utente"] as const;
export type Ruolo = "admin" | "allenatore";
/**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
* Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
* altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore`
* lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034).
*/
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
if (!utenteId) return null;
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", utenteId)
.eq("role", "admin")
.maybeSingle();
.in("role", ["admin", "allenatore"]);
if (error) throw error;
return !!data;
return (data ?? []).map((r) => r.role as Ruolo);
}
function useRuoliQuery() {
const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
return useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoli(utenteId),
staleTime: 30 * 60_000,
});
}
function useRuoli(): Ruolo[] {
return useRuoliQuery().data ?? [];
}
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
export function useRuoliPronti(): boolean {
return !useRuoliQuery().isPending;
}
export function useIsAdmin(): boolean {
const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
const query = useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoloAdmin(utenteId),
staleTime: 30 * 60_000,
});
return query.data === true;
return useRuoli().includes("admin");
}
export function useIsAllenatore(): boolean {
return useRuoli().includes("allenatore");
}
/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */
export function usePuoGestireEventi(): boolean {
const ruoli = useRuoli();
return ruoli.includes("admin") || ruoli.includes("allenatore");
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
import type { Giocatore } from "./crapp-data";
import { useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
import { isAllenatore, useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
import { useIo } from "./rosa";
const KEY = "crapp-user-v1";
@@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null {
return id ? (righe.find((x) => x.id === id) ?? null) : null;
}
/**
* Chi usa l'app è un allenatore (DD-034): decide cosa si vede (niente badge, cacche,
* stagione, obiettivi). I permessi veri stanno in `user_roles` (`usePuoGestireEventi`).
*/
export function useSonoAllenatore(): boolean {
return isAllenatore(useGiocatoreBase());
}
/**
* Il proprio slot solo se è di un giocatore: per rispondere alle presenze, votare,
* dichiarare le cacche o portare i palloni. Per l'allenatore è `null`.
*/
export function useGiocatoreInCampo(): GiocatoreSquadra | null {
const g = useGiocatoreBase();
return g && !isAllenatore(g) ? g : null;
}
/** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */
export function useGiocatoreCorrente(): Giocatore | null {
return useIo();
+22
View File
@@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
@@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificaPersonalizzataRoute =
ApiPublicNotificaPersonalizzataRouteImport.update({
id: '/api/public/notifica-personalizzata',
path: '/api/public/notifica-personalizzata',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificheAttiveRoute =
ApiPublicNotificheAttiveRouteImport.update({
id: '/api/public/notifiche-attive',
@@ -149,6 +156,7 @@ export interface FileRoutesByFullPath {
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -171,6 +179,7 @@ export interface FileRoutesByTo {
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -194,6 +203,7 @@ export interface FileRoutesById {
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -218,6 +228,7 @@ export interface FileRouteTypes {
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
@@ -240,6 +251,7 @@ export interface FileRouteTypes {
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
@@ -262,6 +274,7 @@ export interface FileRouteTypes {
| '/partita/$id'
| '/api/public/apri-sondaggio'
| '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni'
| '/api/public/push-config'
@@ -285,6 +298,7 @@ export interface RootRouteChildren {
PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
@@ -393,6 +407,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifica-personalizzata': {
id: '/api/public/notifica-personalizzata'
path: '/api/public/notifica-personalizzata'
fullPath: '/api/public/notifica-personalizzata'
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifiche-attive': {
id: '/api/public/notifiche-attive'
path: '/api/public/notifiche-attive'
@@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = {
PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
+335 -136
View File
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
Bell,
BellOff,
ChevronDown,
Download,
FileText,
@@ -10,6 +11,7 @@ import {
Image,
Loader2,
Lock,
Send,
Unlink,
UserCheck,
UserPlus,
@@ -19,12 +21,15 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
inRosa,
isAllenatore,
nomeCompleto,
numeroGiaUsato,
prossimoIdGiocatore,
RUOLI,
ruoloVisibile,
useAggiungiGiocatore,
useGiocatoriSquadra,
useImpostaAttivo,
@@ -39,6 +44,7 @@ import {
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
import {
completamento,
completamentoAllenatore,
csvTesseramento,
profiloVuoto,
sezioniComplete,
@@ -49,8 +55,17 @@ import {
import { oggiISO } from "@/lib/palloni-core";
import { scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useNotificheAttive } from "@/lib/notifiche-admin";
import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/admin")({
head: () => ({
@@ -115,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
numero: g.numero,
ruolo: g.ruolo,
email: g.email,
tipo: g.tipo,
};
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
const allenatore = isAllenatore(g);
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
async function confermaSquadra() {
@@ -124,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
toast.error(errore);
return;
}
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
return;
}
@@ -205,28 +223,34 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
className={classiInput}
/>
</Campo>
<Campo label="Numero">
<input
type="number"
min={1}
value={squadraCorrente.numero}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<Select
value={squadraCorrente.ruolo}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
{allenatore ? null : (
<>
<Campo label="Numero">
<input
type="number"
min={1}
value={squadraCorrente.numero}
onChange={(e) =>
setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })
}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<Select
value={squadraCorrente.ruolo}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
</>
)}
</div>
<Campo label="Email">
<input
@@ -246,44 +270,61 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
Salva dati squadra
</button>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
{allenatore ? null : (
<>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({
...tesseramentoCorrente,
numeroTessera: e.target.value || null,
})
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
</>
)}
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
sezioni={sezioniComplete(profiloCorrente)}
/>
{allenatore ? (
<>
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
<CampiAllenatore
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
/>
</>
) : (
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
sezioni={sezioniComplete(profiloCorrente)}
/>
)}
<button
type="button"
onClick={confermaProfilo}
@@ -391,8 +432,9 @@ function SchedaGiocatore({
onToggle: () => void;
}) {
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo);
const { numero } = g;
const allenatore = isAllenatore(g);
const perc = allenatore ? completamentoAllenatore(profilo) : completamento(profilo);
const sezioni = sezioniComplete(profilo);
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
@@ -402,12 +444,12 @@ function SchedaGiocatore({
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
{numero}
{allenatore ? "All" : numero}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold leading-tight">{nome}</p>
<p className="text-xs text-muted-foreground">
{ruolo} · profilo {perc}%
{ruoloVisibile(g)} · profilo {perc}%
</p>
</div>
<ChevronDown
@@ -425,55 +467,65 @@ function SchedaGiocatore({
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</div>
{allenatore ? null : (
<div className="mt-3 flex flex-wrap gap-2">
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</div>
)}
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
</Reveal>
);
}
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
const datiVuoti: DatiSquadra = {
nome: "",
cognome: "",
numero: 1,
ruolo: "",
email: null,
tipo: "giocatore",
};
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
* il database non lo genera da solo. */
/** Form per aggiungere un giocatore alla rosa (DD-017), o un allenatore (DD-034): l'id
* `g<N>` è calcolato in app, il database non lo genera da solo. */
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
const [aperto, setAperto] = useState(false);
const [dati, setDati] = useState<DatiSquadra>(datiVuoti);
const aggiungi = useAggiungiGiocatore();
const allenatore = dati.tipo === "allenatore";
async function conferma() {
const errore = validaDatiSquadra(dati);
@@ -481,13 +533,17 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
toast.error(errore);
return;
}
if (numeroGiaUsato(righe, "", dati.numero)) {
if (!allenatore && numeroGiaUsato(righe, "", dati.numero)) {
toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
return;
}
try {
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati });
toast.success(`${dati.nome} ${dati.cognome} aggiunto alla rosa`);
toast.success(
allenatore
? `${dati.nome} ${dati.cognome} aggiunto come allenatore`
: `${dati.nome} ${dati.cognome} aggiunto alla rosa`,
);
setDati(datiVuoti);
setAperto(false);
} catch (e) {
@@ -502,14 +558,27 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
onClick={() => setAperto(true)}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
<UserPlus className="h-4 w-4" /> Aggiungi giocatore
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
</button>
);
}
return (
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
<h3 className="font-display text-sm uppercase tracking-wide">Nuovo giocatore</h3>
<h3 className="font-display text-sm uppercase tracking-wide">
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
</h3>
<Campo label="Tipo">
<Select
value={dati.tipo}
onChange={(e) =>
setDati({ ...dati, tipo: e.target.value === "allenatore" ? "allenatore" : "giocatore" })
}
>
<option value="giocatore">Giocatore</option>
<option value="allenatore">Allenatore</option>
</Select>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
@@ -527,25 +596,32 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
className={classiInput}
/>
</Campo>
<Campo label="Numero">
<input
type="number"
min={1}
value={dati.numero}
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<Select value={dati.ruolo} onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
{allenatore ? null : (
<>
<Campo label="Numero">
<input
type="number"
min={1}
value={dati.numero}
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<Select
value={dati.ruolo}
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
</>
)}
</div>
<Campo label="Email">
<input
@@ -599,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
<div className="min-w-0">
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
<p className="text-xs text-muted-foreground">
#{g.numero} · {g.ruolo}
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
</p>
</div>
<button
@@ -621,6 +697,32 @@ function Dashboard() {
const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO();
const inviaNotifica = useInviaNotifica();
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
id?: string;
nome: string;
} | null>(null);
const [messaggio, setMessaggio] = useState("");
async function inviaMessaggio() {
if (!destinatarioMessaggio || !messaggio.trim()) return;
try {
const dati = await inviaNotifica.mutateAsync(
destinatarioMessaggio.id
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
: { messaggio: messaggio.trim() },
);
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con notifiche attive per l'invio",
);
setDestinatarioMessaggio(null);
setMessaggio("");
} catch {
toast.error("Non sono riuscito a inviare la notifica");
}
}
if (!admin) {
return (
@@ -636,7 +738,11 @@ function Dashboard() {
);
}
const attivi = squadra.filter((g) => g.attivo);
// Conteggi, tesseramento ed export CSI riguardano solo chi gioca (DD-034).
const attivi = squadra.filter(inRosa);
const allenatori = squadra.filter((g) => g.attivo && isAllenatore(g));
// Le notifiche arrivano anche agli allenatori: nella tab Notifiche ci sono tutti.
const membri = [...attivi, ...allenatori];
const disattivi = squadra.filter((g) => !g.attivo);
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
const certificatiOk = attivi.filter(
@@ -686,6 +792,20 @@ function Dashboard() {
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
{allenatori.length > 0 ? (
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
) : null}
{allenatori.map((g, i) => (
<SchedaGiocatore
key={g.id}
g={g}
profilo={profili[g.id]}
oggi={oggi}
indice={attivi.length + i}
aperta={schedaAperta === g.id}
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
</div>
);
@@ -700,18 +820,45 @@ function Dashboard() {
const contenutoNotifiche = notificheAttive ? (
<>
<StatTile
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
valore={`${membri.filter((g) => notificheAttive.has(g.id)).length}/${membri.length}`}
label="Notifiche attive"
/>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2">
{attivi
.filter((g) => notificheAttive.has(g.id))
.map((g) => (
{membri.map((g) => {
const attiva = notificheAttive.has(g.id);
return (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p>
{attiva ? (
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
{nomeCompleto(g)}
</p>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
))}
);
})}
</div>
</>
) : (
@@ -737,6 +884,58 @@ function Dashboard() {
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]}
/>
<Drawer
open={!!destinatarioMessaggio}
onOpenChange={(aperto) => {
if (!aperto) {
setDestinatarioMessaggio(null);
setMessaggio("");
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Invia messaggio</DrawerTitle>
<DrawerDescription>
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<textarea
value={messaggio}
maxLength={300}
rows={3}
onChange={(e) => setMessaggio(e.target.value)}
placeholder="Scrivi il messaggio da inviare come notifica push…"
className={cn(classiInput, "h-auto")}
/>
</div>
<DrawerFooter>
<button
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}{" "}
Invia
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}
+11 -4
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
import { isAllenatore } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
const schema = z.object({ eventoId: z.string().min(1).max(50) });
@@ -22,15 +24,20 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
if (!partita) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: iscrizioni } = await supabaseAdmin
const { data: tutte } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, p256dh, auth");
.select("endpoint, p256dh, auth, giocatore_id");
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
const allenatori = new Set(
(await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id),
);
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
const titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
for (const iscrizione of iscrizioni) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
@@ -46,7 +53,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
}
}
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
return Response.json({ inviate, destinatari: iscrizioni.length });
},
},
},
@@ -0,0 +1,80 @@
import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
messaggio: z.string().min(1).max(300),
giocatoreId: z.string().min(1).max(50).optional(),
});
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
// `types.ts` non include ancora `notifiche_utente` (M17): stesso aggiramento di
// `giocatori-squadra.server.ts` finché non viene rigenerato.
const client = supabaseAdmin as unknown as SupabaseClient;
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
if (parsed.data.giocatoreId) {
query = query.eq("giocatore_id", parsed.data.giocatoreId);
}
const { data: iscrizioni } = await query;
const titolo = "Messaggio dallo staff";
const testo = parsed.data.messaggio.trim();
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("notifica-personalizzata", error);
}
}
// Storico in-app (M17): indipendente dall'esito della push, e non limitato a chi ha
// un dispositivo iscritto. Senza `giocatoreId` va a tutta la rosa attiva.
let destinatariNotifica: string[];
if (parsed.data.giocatoreId) {
destinatariNotifica = [parsed.data.giocatoreId];
} else {
const { data: rosa } = await client
.from("giocatori_squadra")
.select("id")
.eq("attivo", true);
destinatariNotifica = ((rosa ?? []) as Array<{ id: string }>).map((r) => r.id);
}
if (destinatariNotifica.length > 0) {
await client.from("notifiche_utente").insert(
destinatariNotifica.map((giocatoreId) => ({
giocatore_id: giocatoreId,
tipo: "admin",
titolo,
corpo: testo,
})),
);
}
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
},
},
},
});
+21 -4
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { inRosa, nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server";
@@ -36,14 +37,30 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const squadra = await leggiGiocatoriSquadra();
const rosa = squadra
.filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa);
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 });
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
// notifica a non letta con il testo aggiornato, non fallire per il vincolo UNIQUE.
// `types.ts` non include ancora `notifiche_utente`, stesso aggiramento di
// `giocatori-squadra.server.ts`.
const client = supabaseAdmin as unknown as SupabaseClient;
await client.from("notifiche_utente").upsert(
avvisi.map((a) => ({
giocatore_id: a.giocatoreId,
tipo: "turno_palloni",
titolo: a.titolo,
corpo: a.testo,
evento_id: evento.id,
letta: false,
creato_il: new Date().toISOString(),
})),
{ onConflict: "giocatore_id,evento_id,tipo" },
);
const { data: iscrizioni } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, giocatore_id, p256dh, auth")
+20 -2
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { richiediGestoreEventi } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
@@ -16,7 +17,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
const negato = await richiediGestoreEventi(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
@@ -48,6 +49,23 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
parsed.data.da ? `${parsed.data.da} chiede` : "Serve"
} una conferma: presente, assente o in ritardo?`;
// Storico in-app (M17): un upsert perché ripremere il pulsante deve riportare la
// notifica a non letta, non fallire per il vincolo UNIQUE. `types.ts` non include
// ancora `notifiche_utente`, stesso aggiramento di `giocatori-squadra.server.ts`.
const client = supabaseAdmin as unknown as SupabaseClient;
await client.from("notifiche_utente").upsert(
destinatari.map((giocatoreId) => ({
giocatore_id: giocatoreId,
tipo: "sollecita_presenze",
titolo,
corpo: testo,
evento_id: evento.id,
letta: false,
creato_il: new Date().toISOString(),
})),
{ onConflict: "giocatore_id,evento_id,tipo" },
);
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
+5 -63
View File
@@ -10,7 +10,8 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import {
Drawer,
DrawerClose,
@@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({
component: Calendario,
});
const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
const coloreTipo: Record<Evento["tipo"], string> = {
partita: "var(--accent)",
@@ -47,63 +46,6 @@ const coloreTipo: Record<Evento["tipo"], string> = {
compleanno: "var(--success)",
};
const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
function useMeseNav(initial?: { anno: number; mese: number }) {
const oggi = new Date();
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
const [direzione, setDirezione] = useState(0);
const precedente = () => {
setDirezione(-1);
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
setDirezione(1);
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, direzione, precedente, successivo };
}
function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function Calendario() {
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
@@ -113,9 +55,9 @@ function Calendario() {
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const admin = useIsAdmin();
const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi();
const rosa = useAnagraficaRosa();
const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto();
@@ -302,7 +244,7 @@ function Calendario() {
</Card>
</Section>
{admin ? (
{puoGestire ? (
<div className="px-5 pt-4">
<Link
to="/eventi"
+133 -118
View File
@@ -7,7 +7,13 @@ import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import {
isNostraSquadra,
matchDaPartitaCsi,
partiteGiocate,
raggruppaPerStagione,
stagioneDaPartite,
} from "@/lib/csi-core";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { useEventi } from "@/lib/eventi";
import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
@@ -26,9 +32,7 @@ function LogoPartita({
avversario: string;
}) {
if (nostro) {
return (
<TeamLogo src={LOGO_NOI} className="h-8 w-8 rounded-lg shadow-none" />
);
return <TeamLogo src={LOGO_NOI} className="h-8 w-8 rounded-lg shadow-none" />;
}
if (logoAvversario) {
return <LogoSquadra src={logoAvversario} alt={avversario} className="h-8 w-8" />;
@@ -129,6 +133,8 @@ function Classifica() {
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]);
// Tutte le gare, non solo quelle giocate: a inizio stagione il calendario c'è già.
const stagioneCsi = useMemo(() => stagioneDaPartite(csi?.partite ?? []), [csi]);
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
const eventoIdPerData = useMemo(
@@ -202,124 +208,127 @@ function Classifica() {
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-3">
{tuttiMatch.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const cliccabile = Boolean(eventoId) || !m.scout;
const casa = {
nome: m.casa ? NOME_NOI : m.avversario,
nostro: m.casa,
};
const trasferta = {
nome: m.casa ? m.avversario : NOME_NOI,
nostro: !m.casa,
};
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
const setCasa = m.casa ? m.setNostri : m.setLoro;
const setOspite = m.casa ? m.setLoro : m.setNostri;
const contenuto = (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={casa.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={trasferta.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{setCasa}-{setOspite}
</span>
{cliccabile ? (
<ChevronRight
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-hidden
/>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => {
const puntiCasa = m.casa ? p[0] : p[1];
const puntiOspite = m.casa ? p[1] : p[0];
const setVintoDaNoi = p[0] > p[1];
return (
<div className="space-y-5">
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
<section key={stagione} className="space-y-3">
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
{match.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
const cliccabile = Boolean(eventoId) || !m.scout;
const casa = {
nome: m.casa ? NOME_NOI : m.avversario,
nostro: m.casa,
};
const trasferta = {
nome: m.casa ? m.avversario : NOME_NOI,
nostro: !m.casa,
};
// Badge e parziali in ordine casa–ospite; il colore resta sulla vittoria CRAP.
const setCasa = m.casa ? m.setNostri : m.setLoro;
const setOspite = m.casa ? m.setLoro : m.setNostri;
const contenuto = (
<>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={casa.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
nostro={trasferta.nostro}
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
</div>
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
setVintoDaNoi
? "bg-secondary"
: "bg-muted text-muted-foreground",
"shrink-0 rounded-xl px-2.5 py-1 font-display text-xl",
vinta
? "bg-success text-success-foreground"
: "bg-destructive text-destructive-foreground",
)}
>
{puntiCasa}-{puntiOspite}
{setCasa}-{setOspite}
</span>
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
</div>
</>
);
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
if (eventoId) {
return (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
}
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
// corrispettivo sul portale da mostrare, quelle CSI sì.
return m.scout ? (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
) : (
<Link
key={m.id}
to="/partita-csi/$id"
params={{ id: m.id }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
})}
{cliccabile ? (
<ChevronRight
className="h-5 w-5 shrink-0 text-muted-foreground"
aria-hidden
/>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{m.parziali.map((p, i) => {
const puntiCasa = m.casa ? p[0] : p[1];
const puntiOspite = m.casa ? p[1] : p[0];
const setVintoDaNoi = p[0] > p[1];
return (
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
{puntiCasa}-{puntiOspite}
</span>
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
</div>
</>
);
const aria = `${casa.nome} vs ${trasferta.nome}: dettaglio partita`;
if (eventoId) {
return (
<Link
key={m.id}
to="/partita/$id"
params={{ id: eventoId }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
}
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
// corrispettivo sul portale da mostrare, quelle CSI sì.
return m.scout ? (
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
{contenuto}
</article>
) : (
<Link
key={m.id}
to="/partita-csi/$id"
params={{ id: m.id }}
aria-label={aria}
className="premi block rounded-3xl bg-card p-4 shadow-card ring-1 ring-transparent transition-[box-shadow,transform] active:scale-[0.99] hover:ring-accent/30"
>
{contenuto}
</Link>
);
})}
</section>
))}
</div>
),
[tuttiMatch, eventoIdPerData],
@@ -329,7 +338,13 @@ function Classifica() {
<>
<PageHeader
titolo="Campionato"
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
sottotitolo={
csi
? [csi.girone, "CSI Bologna", stagioneCsi && `Stagione ${stagioneCsi}`]
.filter(Boolean)
.join(" · ")
: "CSI Bologna"
}
/>
<BarraSottosezioni
+300 -77
View File
@@ -1,9 +1,10 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
import {
Drawer,
DrawerClose,
@@ -14,7 +15,8 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { formatData } from "@/lib/crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import {
categoriaEvento,
daCategoria,
@@ -26,7 +28,9 @@ import {
type Evento,
} from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export const Route = createFileRoute("/eventi")({
head: () => ({
@@ -57,18 +61,32 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
];
function GestioneEventi() {
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
// Solo verità (`!io`, gate admin o allenatore): `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento();
const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null);
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const rosa = squadra.filter((g) => g.attivo);
const [confermaModifica, setConfermaModifica] = useState(false);
const [cambiaGiorno, setCambiaGiorno] = useState(false);
const rosa = squadra.filter(inRosa);
if (!io || !admin) {
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
useEffect(() => {
const d = new Date();
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
const ridotto = useMotoRidotto();
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
if (!io || !puoGestire) {
return (
<>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
@@ -85,18 +103,31 @@ function GestioneEventi() {
setBozza((b) => (b ? { ...b, ...patch } : b));
}
async function conferma() {
const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false;
function chiediConferma() {
if (!bozza) return;
if (!bozza.titolo.trim()) {
toast.error("Serve un titolo per l'evento");
return;
}
if (modificaEsistente) {
setConfermaModifica(true);
return;
}
conferma();
}
async function conferma() {
if (!bozza) return;
try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
toast.success("Evento salvato");
setBozza(null);
} catch {
toast.error("Non sono riuscito a salvare l'evento");
} finally {
setConfermaModifica(false);
}
}
@@ -112,19 +143,153 @@ function GestioneEventi() {
}
}
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
const eventiPerGiorno = new Map<number, Evento[]>();
for (const e of eventi) {
if (!e.data.startsWith(mesePrefix)) continue;
const g = Number(e.data.slice(8, 10));
const lista = eventiPerGiorno.get(g) ?? [];
lista.push(e);
eventiPerGiorno.set(g, lista);
}
function apriGiorno(giorno: number) {
setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`);
}
function nuovoNelGiorno() {
if (!giornoSelezionato) return;
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
setCambiaGiorno(false);
setGiornoSelezionato(null);
}
function modificaDalGiorno(e: Evento) {
setBozza(e);
setCambiaGiorno(false);
setGiornoSelezionato(null);
}
function eliminaDalGiorno(e: Evento) {
setDaEliminare(e);
setGiornoSelezionato(null);
}
const eventiGiornoSelezionato = giornoSelezionato
? eventi.filter((e) => e.data === giornoSelezionato)
: [];
return (
<>
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
<div className="px-5 pt-4">
<button
type="button"
onClick={() => setBozza(eventoVuoto())}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento
</button>
</div>
<Section titolo="Calendario">
<Card>
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={precedente}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese precedente"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="font-display-sm text-xl uppercase">
{mesiIT[mese]} {anno}
</span>
<button
type="button"
onClick={successivo}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese successivo"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
{/* Swipe tra mesi con la stessa fisica di `/calendario` (vedi il commento lì). */}
<div className="relative -mx-1 mt-1 overflow-hidden p-1">
<AnimatePresence initial={false} mode="popLayout" custom={direzione}>
<motion.div
key={mesePrefix}
custom={direzione}
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) successivo();
else if (arrivo > 60) precedente();
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * 48 }}
animate={{ opacity: 1, x: 0 }}
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * -48 }}
transition={ridotto ? { duration: 0.2 } : molla.foglio}
className="grid touch-pan-y grid-cols-7 gap-1"
>
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
return (
<button
key={giorno}
type="button"
onClick={() => apriGiorno(giorno)}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
haEventi
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
aria-current={isOggi ? "date" : undefined}
>
{giorno}
</button>
);
})}
</motion.div>
</AnimatePresence>
</div>
{isPending ? (
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="mt-3 space-y-2 text-center text-sm">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
modificare o eliminare.
</p>
)}
</Card>
</Section>
{bozza ? (
<Section
@@ -154,20 +319,39 @@ function GestioneEventi() {
value={bozza.titolo}
maxLength={80}
onChange={(e) => aggiorna({ titolo: e.target.value })}
placeholder="Es. CRAP Volley vs Aurora Nera"
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data">
<input
type="date"
value={bozza.data}
onChange={(e) => aggiorna({ data: e.target.value })}
className={classiInput}
/>
</Campo>
{/* Il giorno arriva già dal calendario: si mostra e basta, il campo data
compare solo per spostare l'evento. */}
{cambiaGiorno ? (
<Campo label="Giorno">
<input
type="date"
value={bozza.data}
onChange={(e) => aggiorna({ data: e.target.value })}
className={classiInput}
/>
</Campo>
) : (
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Giorno
</span>
<div className="mt-1 flex h-10 items-center justify-between gap-1">
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
<button
type="button"
onClick={() => setCambiaGiorno(true)}
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
>
Cambia
</button>
</div>
</div>
)}
<Campo label="Ora">
<input
type="time"
@@ -272,7 +456,7 @@ function GestioneEventi() {
</button>
<button
type="button"
onClick={conferma}
onClick={chiediConferma}
disabled={salva.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
@@ -283,59 +467,98 @@ function GestioneEventi() {
</Section>
) : null}
<Section titolo="Eventi in calendario">
{isPending ? (
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="space-y-2 rounded-3xl bg-card p-4 text-center text-sm shadow-card">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
</p>
<Drawer
open={!!giornoSelezionato}
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
>
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
<DrawerHeader className="relative px-0 pb-2 text-left">
<DrawerTitle className="font-display-lg text-2xl uppercase">
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
</DrawerTitle>
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
<X className="h-4 w-4" />
<span className="sr-only">Chiudi</span>
</DrawerClose>
</DrawerHeader>
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
{eventiGiornoSelezionato.length > 0 ? (
eventiGiornoSelezionato.map((e) => (
<div
key={e.id}
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
{e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
<button
type="button"
onClick={() => modificaDalGiorno(e)}
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Modifica ${e.titolo}`}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => eliminaDalGiorno(e)}
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
aria-label={`Elimina ${e.titolo}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))
) : (
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
</div>
<button
type="button"
onClick={nuovoNelGiorno}
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
</button>
</DrawerContent>
</Drawer>
<Drawer
open={confermaModifica}
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
<DrawerDescription>
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
onClick={conferma}
disabled={salva.isPending}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
Riprova
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
</button>
</div>
) : (
<div className="space-y-2">
{eventi.map((e) => (
<div
key={e.id}
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
<button
type="button"
onClick={() => setBozza(e)}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Modifica ${e.titolo}`}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setDaEliminare(e)}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
aria-label={`Elimina ${e.titolo}`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</Section>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
<DrawerContent>
+81 -61
View File
@@ -1,7 +1,9 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { Reveal } from "@/components/motion/Reveal";
@@ -11,6 +13,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useScoutMatches } from "@/lib/scout-store";
@@ -38,7 +42,10 @@ export const Route = createFileRoute("/")({
});
function Index() {
// `useIo` c'è solo per i giocatori; l'allenatore ha lo slot ma non statistiche (DD-034).
const base = useGiocatoreBase();
const giocatore = useIo();
const allenatore = isAllenatore(base);
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10);
@@ -65,7 +72,8 @@ function Index() {
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
: null;
if (!giocatore) return null;
if (!base || (!giocatore && !allenatore)) return null;
const nome = giocatore?.nome ?? nomeCompleto(base);
return (
<>
@@ -78,7 +86,7 @@ function Index() {
/>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
Ciao {giocatore.nome.split(" ")[0]}
Ciao {nome.split(" ")[0]}
</p>
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
</div>
@@ -86,7 +94,9 @@ function Index() {
<LinkProfilo />
</div>
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
<div
className={cn("mt-6 grid gap-2 text-center", giocatore ? "grid-cols-3" : "grid-cols-2")}
>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
@@ -97,19 +107,23 @@ function Index() {
</p>
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
</div>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
{giocatore ? (
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
) : null}
</div>
</Reveal>
<PromemoriaPalloni />
<CompletaProfilo giocatoreId={giocatore.id} indice={1} />
<AvvisoCertificati />
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
<Section
titolo="Prossimo impegno"
@@ -132,20 +146,22 @@ function Index() {
)}
</Section>
<Section titolo="Da confermare" indice={3}>
<div className="space-y-3">
{daConfermare.length > 0 ? (
daConfermare.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
</div>
</Section>
{giocatore ? (
<Section titolo="Da confermare" indice={3}>
<div className="space-y-3">
{daConfermare.length > 0 ? (
daConfermare.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
</div>
</Section>
) : null}
<Section
titolo="Ultima partita"
@@ -207,43 +223,47 @@ function Index() {
)}
</Section>
<Section
titolo="Obiettivo di squadra"
indice={5}
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<Card>
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
</Card>
) : null}
</Section>
{allenatore ? null : (
<Section
titolo="Obiettivo di squadra"
indice={5}
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<Card>
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
</Card>
) : null}
</Section>
)}
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
{giocatore ? (
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
) : null}
</>
);
}
+13 -6
View File
@@ -14,6 +14,7 @@ import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
@@ -47,6 +48,7 @@ function PartitaDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const admin = useIsAdmin();
const sonoAllenatore = useSonoAllenatore();
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
const { risposte } = usePresenzeEvento(id);
@@ -203,9 +205,12 @@ function PartitaDetail() {
</Section>
{match ? (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
// Badge e cacche non si mostrano all'allenatore (DD-034).
sonoAllenatore ? null : (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
)
) : (
<Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
@@ -219,9 +224,11 @@ function PartitaDetail() {
<ScoutEntry eventoId={evento.id} />
</Section>
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
{sonoAllenatore ? null : (
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
)}
{match ? (
<Section titolo="Pagelle di fine partita">
+232 -181
View File
@@ -14,8 +14,9 @@ import {
} from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
import { useVotiSocial } from "@/lib/badge-social";
import { inizialiDa } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
import {
@@ -24,7 +25,13 @@ import {
pushSupportato,
statoNotifiche,
} from "@/lib/push-client";
import { resetGiocatore } from "@/lib/user-store";
import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
import {
isAllenatore,
nomeCompleto,
ruoloVisibile,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { esci } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { Reveal } from "@/components/motion/Reveal";
@@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({
});
function Profilo() {
// L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034).
const base = useGiocatoreBase();
if (base && isAllenatore(base)) return <ProfiloAllenatorePagina g={base} />;
return <ProfiloGiocatore />;
}
function ProfiloGiocatore() {
const { tab } = Route.useSearch();
const tabIniziale = tab ?? "stagione";
const votiSocial = useVotiSocial();
const g = useIo();
const admin = useIsAdmin();
const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(g?.id);
const impostaAvatarEsiste = useImpostaAvatarEsiste();
const [bust, setBust] = useState(0);
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
useEffect(() => {
setSupportate(pushSupportato());
statoNotifiche()
.then(setNotifiche)
.catch(() => setNotifiche(false));
}, []);
if (!g) return null;
async function cambiaNotifiche() {
if (!g || inCorso) return;
setInCorso(true);
try {
if (notifiche) {
await disattivaNotifiche();
setNotifiche(false);
toast.success("Notifiche disattivate");
} else {
await attivaNotifiche(g.id);
setNotifiche(true);
toast.success("Notifiche attive");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
} finally {
setInCorso(false);
}
}
async function logout() {
try {
await esci();
resetGiocatore();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
}
}
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !g) return;
try {
await caricaAvatar(g.id, file);
setBust(Date.now());
impostaAvatarEsiste(g.id, true);
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
}
}
return (
<>
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
<PageHeader
titolo={g.nome}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
azione={
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
</Link>
}
/>
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
<Reveal className="-mt-6 px-5">
<Card>
<div className="flex flex-col items-center gap-4">
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFile}
/>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
{fotoEsiste.data ? (
<button
type="button"
onClick={async () => {
if (!confirm("Rimuovere l'immagine profilo?")) return;
try {
await rimuoviAvatar(g.id);
setBust(Date.now());
impostaAvatarEsiste(g.id, false);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</Card>
</Reveal>
<CardAvatar id={g.id} fallback={g.iniziali} />
<BarraSottosezioni
key={tabIniziale}
@@ -236,78 +142,223 @@ function Profilo() {
{
id: "impostazioni",
label: "Opzioni",
contenuto: (
<div className="space-y-3">
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-xs text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
: "Palloni, solleciti presenze e avvisi in app"
: "Non supportate su questo dispositivo"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
notifiche
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Bell className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Dashboard amministratore</span>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</Link>
) : null}
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Segnala un bug</span>
<Bug className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
>
<LogOut className="h-4 w-4" />
Esci
</button>
</div>
),
contenuto: <TabOpzioni giocatoreId={g.id} />,
},
]}
/>
</>
);
}
function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) {
const { tab } = Route.useSearch();
// Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs.
const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti";
const nome = nomeCompleto(g);
return (
<>
<PageHeader titolo={nome} sottotitolo={ruoloVisibile(g)} azione={<LinkHome />} />
<CardAvatar id={g.id} fallback={inizialiDa(nome)} />
<BarraSottosezioni
key={tabIniziale}
defaultId={tabIniziale}
riempiLarghezza
voci={[
{ id: "documenti", label: "Docs", contenuto: <ProfiloAllenatore g={g} /> },
{
id: "impostazioni",
label: "Opzioni",
contenuto: <TabOpzioni giocatoreId={g.id} />,
},
]}
/>
</>
);
}
function LinkHome() {
return (
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
</Link>
);
}
/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */
function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(id);
const impostaAvatarEsiste = useImpostaAvatarEsiste();
const [bust, setBust] = useState(0);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await caricaAvatar(id, file);
setBust(Date.now());
impostaAvatarEsiste(id, true);
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
}
}
return (
<Reveal className="-mt-6 px-5">
<Card>
<div className="flex flex-col items-center gap-4">
<Avatar id={id} fallback={fallback} className="h-20 w-20 text-2xl" bust={bust} />
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
{fotoEsiste.data ? (
<button
type="button"
onClick={async () => {
if (!confirm("Rimuovere l'immagine profilo?")) return;
try {
await rimuoviAvatar(id);
setBust(Date.now());
impostaAvatarEsiste(id, false);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</Card>
</Reveal>
);
}
/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */
function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
const admin = useIsAdmin();
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
useEffect(() => {
setSupportate(pushSupportato());
statoNotifiche()
.then(setNotifiche)
.catch(() => setNotifiche(false));
}, []);
async function cambiaNotifiche() {
if (inCorso) return;
setInCorso(true);
try {
if (notifiche) {
await disattivaNotifiche();
setNotifiche(false);
toast.success("Notifiche disattivate");
} else {
await attivaNotifiche(giocatoreId);
setNotifiche(true);
toast.success("Notifiche attive");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
} finally {
setInCorso(false);
}
}
async function logout() {
try {
await esci();
resetGiocatore();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
}
}
return (
<div className="space-y-3">
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-xs text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
: "Palloni, solleciti presenze e avvisi in app"
: "Non supportate su questo dispositivo"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
notifiche
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Bell className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Dashboard amministratore</span>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</Link>
) : null}
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Segnala un bug</span>
<Bug className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
>
<LogOut className="h-4 w-4" />
Esci
</button>
</div>
);
}
+114 -45
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data";
import { formatData, inizialiDa } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import {
useRosa,
@@ -15,6 +15,13 @@ import {
type CriterioClassifica,
} from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
import {
isAllenatore,
nomeCompleto,
ruoloVisibile,
useGiocatoriSquadra,
} from "@/lib/giocatori-squadra";
import { useSonoAllenatore } from "@/lib/user-store";
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
import { partiteGiocate } from "@/lib/csi-core";
@@ -87,6 +94,14 @@ function valore(
function Squadra() {
const rosa = useRosa();
// Gli allenatori stanno in Rosa con i giocatori, senza numero né statistiche (DD-034).
const { righe: squadra } = useGiocatoriSquadra();
const allenatori = useMemo(() => squadra.filter((g) => g.attivo && isAllenatore(g)), [squadra]);
// Chi allena non vede badge, cacche né obiettivi (DD-034).
const sonoAllenatore = useSonoAllenatore();
const criteriVisibili = sonoAllenatore
? criteri.filter((c) => c.id !== "cacchePartita")
: criteri;
const mese = usePresenzeUltimoMeseTutti();
const scoutMatches = useScoutMatches();
const { voti: pagelle } = usePagelle();
@@ -115,9 +130,36 @@ function Squadra() {
const contenutoRosa = useMemo(
() => (
<div className="space-y-2">
{allenatori.map((a) => (
<article
key={a.id}
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
>
<Avatar
id={a.id}
fallback={inizialiDa(nomeCompleto(a))}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{nomeCompleto(a)}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={ruoloVisibile(a)} />
{a.nascita ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
</span>
) : null}
</span>
</span>
</article>
))}
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const sbloccati = sonoAllenatore
? []
: [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
@@ -169,12 +211,14 @@ function Squadra() {
{isOpen ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
{g.nascita ? (
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
</div>
) : null}
<div className="mt-3 grid grid-cols-3 gap-2">
{[
@@ -183,7 +227,9 @@ function Squadra() {
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
...(sonoAllenatore
? []
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
@@ -194,37 +240,45 @@ function Squadra() {
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
{sonoAllenatore ? null : (
<>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</>
)}
</div>
) : null}
@@ -233,7 +287,7 @@ function Squadra() {
})}
</div>
),
[rosa, aperto, mese],
[rosa, aperto, mese, allenatori, sonoAllenatore],
);
const contenutoStats = useMemo(
@@ -269,7 +323,7 @@ function Squadra() {
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
{criteriVisibili.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
@@ -324,7 +378,18 @@ function Squadra() {
</div>
</div>
),
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
[
mediaPresenze,
matchGiocati,
pagelle,
team,
filtroAperto,
criterio,
ordinati,
rank,
max,
criteriVisibili,
],
);
const contenutoObiettivi = useMemo(
@@ -448,8 +513,12 @@ function Squadra() {
voci={[
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Stats", contenuto: contenutoStats },
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
...(sonoAllenatore
? []
: [
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
]),
]}
/>
</>
@@ -0,0 +1,162 @@
-- M17 — Centro notifiche in-app (badge non lette accanto al profilo)
--
-- Fino ad oggi le uniche notifiche erano push OS/browser (`push_subscriptions`, vedi
-- docs/modules/notifiche.md), senza storico né stato letto/non letto. Questa migration
-- aggiunge `notifiche_utente`: una riga per notifica, per giocatore, con un flag `letta`.
-- Quattro sorgenti generano righe, mai il client:
--
-- 1. Messaggio libero dell'admin (`notifica-personalizzata.ts`, invariato lato push).
-- 2. Promemoria automatico prima di un evento, 24h e 3h prima (due `cron.schedule`,
-- estensione `pg_cron` già abilitata dal 31/07/2026 e mai usata finora). Volutamente
-- non c'è una notifica sulla sola creazione dell'evento: conta l'avvicinarsi della
-- data, non il momento in cui è stato messo in calendario.
-- 3. Turno palloni (`promemoria-palloni.ts`, avviato a mano da un admin dalla pagina
-- evento), in parallelo alla push.
-- 4. Sollecito presenze (`sollecita-presenze.ts`, stesso innesco manuale), in parallelo
-- alla push.
--
-- I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`
-- (src/lib/eventi.ts) ed `evento_permette_voto()` (M13): `convocati` vuoto significa tutta
-- la rosa attiva, altrimenti solo gli id in `convocati`.
CREATE TABLE public.notifiche_utente (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
tipo text NOT NULL CHECK (
tipo IN (
'admin',
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni',
'sollecita_presenze'
)
),
titolo text NOT NULL,
corpo text NOT NULL DEFAULT '',
evento_id text REFERENCES public.eventi_app(id) ON DELETE CASCADE,
letta boolean NOT NULL DEFAULT false,
creato_il timestamptz NOT NULL DEFAULT now(),
-- Per tipo='admin' evento_id è NULL: NULL non è mai uguale a NULL, quindi la UNIQUE non
-- si applica e ogni messaggio admin resta una riga separata (corretto). Per i tipi legati
-- a un evento, un secondo invio (cron che gira di nuovo, admin che preme di nuovo il
-- pulsante) aggiorna la stessa riga invece di duplicarla — vedi gli `upsert` sotto.
UNIQUE (giocatore_id, evento_id, tipo)
);
COMMENT ON TABLE public.notifiche_utente IS
'Centro notifiche in-app per giocatore: messaggi admin, promemoria evento, turno palloni, sollecito presenze.';
CREATE INDEX notifiche_utente_giocatore_letta_idx
ON public.notifiche_utente (giocatore_id, letta);
GRANT SELECT, UPDATE, DELETE ON public.notifiche_utente TO authenticated;
GRANT ALL ON public.notifiche_utente TO service_role;
ALTER TABLE public.notifiche_utente ENABLE ROW LEVEL SECURITY;
-- Un account può essere collegato a più slot giocatore (`giocatori_squadra.auth_user_id`):
-- vede le notifiche di tutti i suoi slot, il client filtra per il giocatore selezionato in
-- localStorage (stesso pattern di `LinkProfilo`/`useGiocatoreId`).
CREATE POLICY "Il giocatore legge le proprie notifiche" ON public.notifiche_utente
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
)
);
-- Solo per segnarle come lette: nessuna policy INSERT per authenticated, le righe nascono
-- solo da funzioni `SECURITY DEFINER` o dalla service role (route admin).
CREATE POLICY "Il giocatore segna le proprie notifiche come lette" ON public.notifiche_utente
FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
)
);
-- Eliminazione (swipe o pulsante nel pannello): non c'è pulizia automatica delle notifiche
-- vecchie, quindi è l'unico modo per un giocatore di toglierle di mezzo per sempre.
CREATE POLICY "Il giocatore elimina le proprie notifiche" ON public.notifiche_utente
FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = notifiche_utente.giocatore_id AND g.auth_user_id = auth.uid()
)
);
-- --- Destinatari di un evento: stessa convenzione di convocatiEvento()/evento_permette_voto() ---
CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
RETURNS SETOF text
LANGUAGE sql STABLE AS $$
SELECT g.id
FROM public.eventi_app e
JOIN public.giocatori_squadra g
ON g.attivo = true
AND (cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
WHERE e.id = p_evento_id;
$$;
-- --- Promemoria evento imminente (pg_cron) -----------------------------------------------
--
-- `data`/`ora` sono salvate come ora locale Italia (stessa convenzione di
-- `m10_azzera_turni_palloni_allenamenti`), quindi il confronto con `now()` (timestamptz)
-- passa esplicitamente da `AT TIME ZONE 'Europe/Rome'`.
--
-- `eventi_app.ora` è testo libero (`<input type="time">` lato client, ma la form non
-- impedisce di svuotarlo, vedi `src/routes/eventi.tsx`): un `::time` diretto su un valore
-- non valido manda in errore l'intera istruzione, e con essa **tutti** i promemoria di
-- quel giro di cron, non solo quelli dell'evento incriminato. `ora_evento_a_time()` assorbe
-- l'errore riga per riga: un evento con l'ora scritta male viene solo escluso.
CREATE OR REPLACE FUNCTION public.ora_evento_a_time(p_ora text)
RETURNS time
LANGUAGE plpgsql IMMUTABLE AS $$
BEGIN
RETURN p_ora::time;
EXCEPTION WHEN OTHERS THEN
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.genera_promemoria_eventi(p_tipo text, p_finestra interval)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo, evento_id)
SELECT d, p_tipo, 'Promemoria: ' || e.titolo,
to_char(e.data, 'DD/MM/YYYY') || ' alle ' || e.ora,
e.id
FROM public.eventi_app e
CROSS JOIN LATERAL public.giocatori_destinatari_evento(e.id) AS d
WHERE public.ora_evento_a_time(e.ora) IS NOT NULL
AND ((e.data + public.ora_evento_a_time(e.ora)) AT TIME ZONE 'Europe/Rome') - now()
BETWEEN interval '0' AND p_finestra
ON CONFLICT (giocatore_id, evento_id, tipo) DO NOTHING;
END;
$$;
SELECT cron.schedule(
'promemoria-eventi-24h',
'0 * * * *',
$$ SELECT public.genera_promemoria_eventi('evento_promemoria_24h', interval '24 hours') $$
);
SELECT cron.schedule(
'promemoria-eventi-3h',
'*/15 * * * *',
$$ SELECT public.genera_promemoria_eventi('evento_promemoria_3h', interval '3 hours') $$
);
@@ -0,0 +1,124 @@
-- M18 — Data di nascita pubblica a tutta la squadra (DD-031)
--
-- In "Squadra" la data di nascita mostrava "Invalid Date" per chi l'aveva inserita da solo
-- dal proprio Profilo: quel valore finisce in `profili_giocatore.data_nascita`, tabella con
-- RLS "solo il proprio profilo o admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()`
-- (src/lib/rosa.ts). Quelle funzioni leggevano solo la mappa statica `nascitaPerId` del seed
-- storico dei 17 giocatori originali (src/lib/crapp-data.ts) — pubblica perché bundlata nel
-- client, ma senza voce per chi si è aggiunto dopo.
--
-- Questa migration aggiunge la colonna pubblica prevista fin da DD-015 ("giocatori_squadra
-- non ha ancora questa colonna, DD-015 follow-up") e la tiene sincronizzata con
-- `profili_giocatore.data_nascita`: un solo input nel form Profilo, due destinazioni — quella
-- amministrativa esistente (RLS invariata) e questo specchio pubblico, leggibile da tutta la
-- rosa attiva con la stessa policy di sempre.
ALTER TABLE public.giocatori_squadra ADD COLUMN nascita date;
-- Backfill una tantum dei 17 giocatori storici (stessi valori di `rosaCSI` in
-- src/lib/crapp-data.ts, stesso ordine g1..g17 del seed di M1). Il trigger
-- `enforce_giocatori_squadra_update` (attivo dalla migration M1) rifiuterebbe questi UPDATE:
-- gira in un contesto senza `auth.uid()`, quindi non passa né il ramo admin né quello di
-- reclamazione. Va disabilitato solo per il backfill e riattivato subito dopo.
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
UPDATE public.giocatori_squadra SET nascita = '1997-08-30' WHERE id = 'g1';
UPDATE public.giocatori_squadra SET nascita = '1996-12-07' WHERE id = 'g2';
UPDATE public.giocatori_squadra SET nascita = '2000-04-28' WHERE id = 'g3';
UPDATE public.giocatori_squadra SET nascita = '1995-05-01' WHERE id = 'g4';
UPDATE public.giocatori_squadra SET nascita = '1995-08-13' WHERE id = 'g5';
UPDATE public.giocatori_squadra SET nascita = '1996-02-01' WHERE id = 'g6';
UPDATE public.giocatori_squadra SET nascita = '1997-07-04' WHERE id = 'g7';
UPDATE public.giocatori_squadra SET nascita = '1994-05-28' WHERE id = 'g8';
UPDATE public.giocatori_squadra SET nascita = '2003-04-04' WHERE id = 'g9';
UPDATE public.giocatori_squadra SET nascita = '1998-11-30' WHERE id = 'g10';
UPDATE public.giocatori_squadra SET nascita = '2000-02-02' WHERE id = 'g11';
UPDATE public.giocatori_squadra SET nascita = '1999-10-03' WHERE id = 'g12';
UPDATE public.giocatori_squadra SET nascita = '2000-09-16' WHERE id = 'g13';
UPDATE public.giocatori_squadra SET nascita = '1996-12-13' WHERE id = 'g14';
UPDATE public.giocatori_squadra SET nascita = '1993-03-24' WHERE id = 'g15';
UPDATE public.giocatori_squadra SET nascita = '2001-04-18' WHERE id = 'g16';
UPDATE public.giocatori_squadra SET nascita = '1994-05-20' WHERE id = 'g17';
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
-- Il trigger di sync (sotto) è SECURITY DEFINER e bypassa la RLS in scrittura su
-- giocatori_squadra (di proprietà dell'owner della tabella), ma `enforce_giocatori_squadra_update`
-- resta un trigger BEFORE UPDATE che scatta comunque: senza questo ramo, l'update annidato
-- fallirebbe con l'eccezione già in vigore per chi non è admin.
--
-- Non apre una scrittura diretta dal client: la policy di update per un non-admin resta
-- "Users can claim unassigned roster slot" (USING auth_user_id IS NULL) — una riga già
-- collegata non la supera nemmeno, quindi l'unica strada che raggiunge questo ramo è la
-- funzione di sync qui sotto, che gira con privilegi propri e ignora la RLS.
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
RETURN NEW;
END IF;
IF OLD.auth_user_id IS NULL
AND NEW.auth_user_id = auth.uid()
AND OLD.email IS NOT NULL
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
-- Slot già collegato al chiamante: può cambiare solo `nascita` (M18). È la via percorsa
-- dal trigger di sync qui sotto, mai da un client (vedi commento sopra).
IF OLD.auth_user_id IS NOT NULL
AND OLD.auth_user_id = auth.uid()
AND NEW.auth_user_id IS NOT DISTINCT FROM OLD.auth_user_id
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
END;
$$;
CREATE OR REPLACE FUNCTION public.sincronizza_nascita_pubblica()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
UPDATE public.giocatori_squadra SET nascita = NULL WHERE id = OLD.giocatore_id;
RETURN OLD;
END IF;
UPDATE public.giocatori_squadra SET nascita = NEW.data_nascita WHERE id = NEW.giocatore_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER sincronizza_nascita_pubblica
AFTER INSERT OR UPDATE OR DELETE ON public.profili_giocatore
FOR EACH ROW
EXECUTE FUNCTION public.sincronizza_nascita_pubblica();
@@ -0,0 +1,22 @@
-- M19 — Backfill della nascita pubblica per i profili già esistenti (fix di M18)
--
-- M18 ha aggiunto `giocatori_squadra.nascita` e un trigger che la sincronizza da
-- `profili_giocatore.data_nascita`, ma quel trigger scatta solo sulle scritture *future*:
-- chi aveva già compilato il proprio profilo (data_nascita) prima che M18 fosse applicata è
-- rimasto con `nascita` vuota, mentre M18 aveva backfillato a mano solo i 17 giocatori del
-- seed storico. Effetto visibile: in Squadra la data di nascita compariva solo per quei 17,
-- non per chi l'aveva inserita dal Profilo prima di oggi.
--
-- Stesso motivo di M18 per disabilitare temporaneamente `enforce_giocatori_squadra_update`:
-- gira in un contesto senza `auth.uid()`, quindi non passerebbe nessun ramo del trigger.
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
-- Ha la precedenza sui valori hardcoded di M18 per chi, tra i 17 storici, ha anche corretto
-- la propria data dal Profilo nel frattempo: `profili_giocatore` è la fonte più aggiornata.
UPDATE public.giocatori_squadra gs
SET nascita = pg.data_nascita
FROM public.profili_giocatore pg
WHERE pg.giocatore_id = gs.id;
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
@@ -0,0 +1,6 @@
-- M20 — Nuovo valore `allenatore` per `app_role` (DD-034).
--
-- Sta da solo perché Postgres non permette di usare un valore di enum nella stessa
-- transazione che lo aggiunge: le policy e le funzioni che lo citano stanno in M21.
ALTER TYPE public.app_role ADD VALUE IF NOT EXISTS 'allenatore';
@@ -0,0 +1,206 @@
-- M21 — Ruolo allenatore (DD-034, docs/modules/allenatore.md).
--
-- L'allenatore è una riga di giocatori_squadra con `tipo = 'allenatore'`: si collega per
-- email come i giocatori (DD-018) e riusa profilo, avatar e compleanno. Non gioca, quindi
-- non ha numero di maglia né ruolo in campo, non risponde alle presenze, non dichiara
-- cacche, non vota e non viene votato. Gestisce gli eventi come un admin.
--
-- Il permesso vive in user_roles (DD-011) come valore `allenatore` di app_role (M20). Non
-- lo scrive nessuno a mano: lo tiene allineato un trigger su giocatori_squadra, quindi è
-- l'admin a concederlo registrando lo slot, e l'allenatore non può auto-promuoversi perché
-- `tipo` lo cambia solo un admin.
-- --- Colonna tipo --------------------------------------------------------------------------
ALTER TABLE public.giocatori_squadra
ADD COLUMN tipo text NOT NULL DEFAULT 'giocatore'
CONSTRAINT giocatori_squadra_tipo_valido CHECK (tipo IN ('giocatore', 'allenatore'));
COMMENT ON COLUMN public.giocatori_squadra.tipo IS
'giocatore (in rosa, convocabile, votabile) oppure allenatore (gestisce gli eventi, fuori '
'dalla rosa di gioco). Scrivibile solo da un admin (DD-034).';
-- L'allenatore non ha numero di maglia: resta obbligatorio solo per i giocatori.
ALTER TABLE public.giocatori_squadra ALTER COLUMN numero DROP NOT NULL;
ALTER TABLE public.giocatori_squadra
ADD CONSTRAINT giocatori_squadra_numero_giocatori
CHECK (tipo = 'allenatore' OR numero IS NOT NULL);
-- --- Trigger sugli slot: tipo solo admin, nome e cognome anche all'allenatore ---------------
--
-- Stessi due rami di M18 (reclamo dello slot libero, sync della nascita sul proprio slot),
-- con `tipo` tra i campi bloccati. Il secondo ramo lascia cambiare nome e cognome solo se
-- lo slot è di un allenatore: per un giocatore restano dati squadra gestiti dall'admin.
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
RETURN NEW;
END IF;
IF OLD.auth_user_id IS NULL
AND NEW.auth_user_id = auth.uid()
AND OLD.email IS NOT NULL
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
-- Slot già collegato al chiamante: cambia `nascita` (sync di M18) e, se è un allenatore,
-- nome e cognome.
IF OLD.auth_user_id IS NOT NULL
AND OLD.auth_user_id = auth.uid()
AND NEW.auth_user_id IS NOT DISTINCT FROM OLD.auth_user_id
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND (OLD.tipo = 'allenatore' OR NEW.nome IS NOT DISTINCT FROM OLD.nome)
AND (OLD.tipo = 'allenatore' OR NEW.cognome IS NOT DISTINCT FROM OLD.cognome)
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
AND NEW.tipo IS NOT DISTINCT FROM OLD.tipo
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
END;
$$;
-- Fino a qui un giocatore non poteva aggiornare una riga già collegata (solo il reclamo di
-- uno slot libero, M1): l'allenatore ora può farlo sulla propria, per nome e cognome. Il
-- trigger sopra decide quali campi.
CREATE POLICY "L'allenatore aggiorna il proprio slot" ON public.giocatori_squadra
FOR UPDATE TO authenticated
USING (auth_user_id = auth.uid() AND tipo = 'allenatore')
WITH CHECK (auth_user_id = auth.uid() AND tipo = 'allenatore');
-- --- Ruolo in user_roles allineato allo slot ---------------------------------------------
--
-- Un account ha il ruolo `allenatore` finché è collegato a uno slot attivo di tipo
-- allenatore. Scollegare, disattivare o cambiare tipo lo toglie.
CREATE OR REPLACE FUNCTION public.sincronizza_ruolo_allenatore()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD.auth_user_id IS NOT NULL
AND (NEW.auth_user_id IS DISTINCT FROM OLD.auth_user_id
OR NEW.tipo <> 'allenatore' OR NOT NEW.attivo) THEN
DELETE FROM public.user_roles
WHERE user_id = OLD.auth_user_id AND role = 'allenatore'::public.app_role;
END IF;
IF NEW.auth_user_id IS NOT NULL AND NEW.tipo = 'allenatore' AND NEW.attivo THEN
INSERT INTO public.user_roles (user_id, role)
VALUES (NEW.auth_user_id, 'allenatore'::public.app_role)
ON CONFLICT (user_id, role) DO NOTHING;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER sincronizza_ruolo_allenatore
AFTER INSERT OR UPDATE OF auth_user_id, tipo, attivo ON public.giocatori_squadra
FOR EACH ROW
EXECUTE FUNCTION public.sincronizza_ruolo_allenatore();
-- --- Eventi: admin o allenatore -----------------------------------------------------------
DROP POLICY "Gli admin gestiscono gli eventi" ON public.eventi_app;
CREATE POLICY "Admin e allenatori gestiscono gli eventi" ON public.eventi_app
FOR ALL TO authenticated
USING (
public.has_role(auth.uid(), 'admin'::public.app_role)
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
)
WITH CHECK (
public.has_role(auth.uid(), 'admin'::public.app_role)
OR public.has_role(auth.uid(), 'allenatore'::public.app_role)
);
-- --- Presenze e cacche: solo i giocatori rispondono per sé --------------------------------
DROP POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze;
CREATE POLICY "Ognuno gestisce la propria presenza" ON public.risposte_presenze
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
)
);
DROP POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita;
CREATE POLICY "Ognuno dichiara le proprie cacche" ON public.cacche_partita
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid() AND g.tipo = 'giocatore'
)
);
-- --- Voti: l'allenatore non vota e non viene votato ---------------------------------------
--
-- Stessa firma di M13, quindi le policy di pagelle, MVP e badge social la usano già.
CREATE OR REPLACE FUNCTION public.evento_permette_voto(
p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM public.eventi_app e
WHERE e.id = p_match_id
AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati))
AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati))
AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse)
)
AND NOT EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id IN (p_votante_id, p_votato_id) AND g.tipo = 'allenatore'
);
$$;
-- --- Promemoria evento: anche agli allenatori, convocati o no -----------------------------
CREATE OR REPLACE FUNCTION public.giocatori_destinatari_evento(p_evento_id text)
RETURNS SETOF text
LANGUAGE sql STABLE AS $$
SELECT g.id
FROM public.eventi_app e
JOIN public.giocatori_squadra g
ON g.attivo = true
AND (g.tipo = 'allenatore' OR cardinality(e.convocati) = 0 OR g.id = ANY(e.convocati))
WHERE e.id = p_evento_id;
$$;
+8
View File
@@ -5,6 +5,12 @@
-- I path dei file puntano a oggetti che nel bucket non esistono: i pulsanti di download
-- falliscono finché non carichi qualcosa dall'app o dallo Studio (http://127.0.0.1:54323).
-- Il trigger di sync M18 (`sincronizza_nascita_pubblica`) aggiorna `giocatori_squadra.nascita`
-- appena questo INSERT tocca `data_nascita`, ma qui gira senza un JWT reale: né il ramo admin
-- né quello del proprietario dello slot del trigger di sicurezza passerebbero. Va disabilitato
-- solo per questo seed, mai un problema in produzione dove il seed non gira.
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
INSERT INTO public.profili_giocatore
(giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email,
documento_tipo, documento_numero, documento_rilasciato_da,
@@ -29,6 +35,8 @@ VALUES
'2027-09-15', 'g2/certificato.pdf', NULL)
ON CONFLICT (giocatore_id) DO NOTHING;
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
-- Il primo amministratore non si può seminare qui: `user_roles.user_id` punta a un utente
-- di `auth.users`, che su un database appena creato non esiste ancora. Dopo il primo login
-- (in locale come in produzione) basta una riga:
+8 -7
View File
@@ -13,7 +13,8 @@ bun test/unit/badges.test.ts # un singolo file
```
Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test
non può inquinare gli altri.
non può inquinare gli altri. bun esegue il TypeScript senza controllarne i tipi: un
test può risultare verde anche con errori di tipo, che trova solo `npm run typecheck`.
Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati,
senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`.
@@ -23,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) e la funzione di bonifica delle righe orfane (`bonifica-evento`, M16) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
| Cartella | Cosa verifica | Serve rete? |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Database locale in Docker
+7
View File
@@ -230,6 +230,13 @@ try {
}
});
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
assert.equal(
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
401,
);
});
await prova("un token malformato non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST",
@@ -0,0 +1,134 @@
/**
* Backfill di M19 (`m19_backfill_nascita_da_profili_esistenti`):
* `bun test/integration/backfill-nascita-esistenti.test.ts`.
*
* M18 ha aggiunto `giocatori_squadra.nascita`, sincronizzata da un trigger su
* `profili_giocatore` — ma solo per le scritture *future*. In produzione questo ha lasciato
* scoperti i profili già compilati **prima** di M18: la nascita restava vuota finché
* `m19_backfill_nascita_da_profili_esistenti` non ha rieseguito una tantum lo stesso
* allineamento (vedi DD-031, aggiornamento del 15/09/2026).
*
* A differenza della bonifica eventi orfani (M15→M16, `bonifica_dati_evento_orfani()`),
* questo backfill non è stato reso una funzione richiamabile: qui si verifica lo stesso
* pattern SQL (`UPDATE giocatori_squadra ... FROM profili_giocatore`, con
* `enforce_giocatori_squadra_update` temporaneamente disattivato) eseguendolo via `psql`
* dentro il container Postgres locale — PostgREST non espone un update multi-tabella come
* RPC, quindi qui serve accesso diretto al database, non solo alla REST.
*
* Simula un profilo "storico" (inserito con il trigger di sync disattivato, come se
* esistesse da prima di M18) e verifica che il backfill lo recuperi. Gira solo sullo stack
* locale con Docker attivo: usa lo slot `g9` della rosa seed, ripristinato alla fine.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("backfill nascita profili esistenti", "stack locale non attivo (npx supabase start)");
riepilogo("backfill-nascita-esistenti");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
const projectId = /project_id\s*=\s*"([^"]+)"/.exec(
readFileSync(new URL("../../supabase/config.toml", import.meta.url), "utf8"),
)?.[1];
const container = projectId ? `supabase_db_${projectId}` : null;
const dockerDisponibile =
!!container && spawnSync("docker", ["inspect", container], { stdio: "ignore" }).status === 0;
if (!dockerDisponibile) {
salta("backfill nascita profili esistenti", "container Postgres locale non trovato");
riepilogo("backfill-nascita-esistenti");
} else {
console.log(`backfill nascita profili esistenti su ${URL_BASE} (container ${container})`);
function eseguiSql(sql: string): string {
const esito = spawnSync(
"docker",
["exec", "-i", container!, "psql", "-U", "postgres", "-d", "postgres"],
{
input: sql,
encoding: "utf8",
},
);
if (esito.status !== 0) throw new Error(`psql fallito: ${esito.stderr || esito.stdout}`);
return esito.stdout;
}
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function leggiNascita(): Promise<string | null> {
const res = await rest("giocatori_squadra?id=eq.g9&select=nascita", SERVIZIO);
const righe = (await res.json()) as Array<{ nascita: string | null }>;
return righe[0]?.nascita ?? null;
}
try {
await prova(
"il backfill recupera la nascita di un profilo compilato prima di M18",
async () => {
// g9 è uno dei 17 storici: il backfill hardcoded di M18 le ha già dato una nascita
// pubblica. Per simulare lo stato "prima di M19" — un profilo compilato ma non
// ancora sincronizzato — va prima azzerata, poi inserito il profilo con il trigger
// di sync disattivato (come se esistesse da prima che M18 lo introducesse).
eseguiSql(`
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
UPDATE public.giocatori_squadra SET nascita = NULL WHERE id = 'g9';
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
ALTER TABLE public.profili_giocatore DISABLE TRIGGER sincronizza_nascita_pubblica;
INSERT INTO public.profili_giocatore (giocatore_id, data_nascita)
VALUES ('g9', '1988-07-21');
ALTER TABLE public.profili_giocatore ENABLE TRIGGER sincronizza_nascita_pubblica;
`);
assert.equal(
await leggiNascita(),
null,
"prima del backfill la nascita pubblica è ancora vuota",
);
// Stesso corpo esatto di m19_backfill_nascita_da_profili_esistenti.sql.
eseguiSql(`
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
UPDATE public.giocatori_squadra gs
SET nascita = pg.data_nascita
FROM public.profili_giocatore pg
WHERE pg.giocatore_id = gs.id;
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
`);
assert.equal(
await leggiNascita(),
"1988-07-21",
"dopo il backfill la nascita del profilo preesistente è recuperata",
);
},
);
} finally {
// g9 (Camilla Esposito) è uno dei 17 storici: il backfill di M18 le aveva già dato
// '2003-04-04' (src/lib/crapp-data.ts) prima che questo test la sovrascrivesse — va
// ripristinato quel valore, non svuotato, per non alterare lo stato del seed.
eseguiSql(`
ALTER TABLE public.giocatori_squadra DISABLE TRIGGER enforce_giocatori_squadra_update;
DELETE FROM public.profili_giocatore WHERE giocatore_id = 'g9';
UPDATE public.giocatori_squadra SET nascita = '2003-04-04' WHERE id = 'g9';
ALTER TABLE public.giocatori_squadra ENABLE TRIGGER enforce_giocatori_squadra_update;
`);
riepilogo("backfill-nascita-esistenti");
}
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Data di nascita pubblica a tutta la squadra (M18): `bun test/integration/nascita-pubblica.test.ts`.
*
* Prima di questa migration `giocatori_squadra` non aveva una colonna `nascita`: la Squadra
* mostrava "Invalid Date" per chi l'aveva inserita da solo dal Profilo, perché quel valore
* finiva solo in `profili_giocatore.data_nascita`, tabella con RLS "solo il proprio profilo
* o admin" — mai letta dall'anagrafica pubblica. Qui si verifica che il trigger di sync
* (`sincronizza_nascita_pubblica`) propaghi il valore a `giocatori_squadra.nascita`, visibile
* a **qualsiasi** giocatore (non solo l'interessato o un admin), che lo azzeri quando il
* profilo viene cancellato, e che il client non possa scriverlo direttamente.
*
* Gira solo sullo stack locale (`npx supabase start`): usa lo slot `g6` della rosa seed,
* ripristinato alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("nascita pubblica", "stack locale non attivo (npx supabase start)");
riepilogo("nascita-pubblica");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`nascita pubblica su ${URL_BASE}`);
const GIOCATORE = "g6";
const PASSWORD = "prova-nascita-pubblica-123";
const idUtenti: string[] = [];
let tokenAdmin = "";
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function creaUtente(email: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
async function leggiNascita(token: string): Promise<string | null> {
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}&select=nascita`, token);
const righe = (await res.json()) as Array<{ nascita: string | null }>;
return righe[0]?.nascita ?? null;
}
try {
const emailAdmin = `test-nascita-admin-${Date.now()}@example.test`;
const emailGiocatore = `test-nascita-giocatore-${Date.now()}@example.test`;
const emailAltro = `test-nascita-altro-${Date.now()}@example.test`;
const idAdmin = await creaUtente(emailAdmin);
const idGiocatore = await creaUtente(emailGiocatore);
const idAltro = await creaUtente(emailAltro);
idUtenti.push(idAdmin, idGiocatore, idAltro);
await rest("user_roles", SERVIZIO, {
method: "POST",
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
tokenAdmin = await accedi(emailAdmin);
const tokenGiocatore = await accedi(emailGiocatore);
const tokenAltro = await accedi(emailAltro);
const collega = (utente: string | null) =>
rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: utente }),
});
await collega(idGiocatore);
await prova(
"il giocatore salva la propria nascita e diventa visibile a un altro giocatore",
async () => {
const res = await rest("profili_giocatore", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "2002-06-15" }),
});
assert.ok(res.ok, `salvataggio profilo: ${res.status} ${await res.text()}`);
assert.equal(await leggiNascita(tokenAltro), "2002-06-15", "visibile a un altro giocatore");
assert.equal(await leggiNascita(tokenAdmin), "2002-06-15", "visibile all'admin");
},
);
await prova("il client non può scrivere direttamente la nascita pubblica", async () => {
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, {
method: "PATCH",
body: JSON.stringify({ nascita: "1900-01-01" }),
});
assert.equal(
await leggiNascita(tokenAdmin),
"2002-06-15",
"il valore resta quello scritto dalla sync, non quello del tentativo diretto",
);
});
await prova("cancellare il profilo azzera la nascita pubblica", async () => {
const res = await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, {
method: "DELETE",
});
assert.ok(res.ok, `cancellazione profilo: ${res.status} ${await res.text()}`);
assert.equal(await leggiNascita(tokenAdmin), null, "torna NULL");
});
await prova("un admin può impostare la nascita per conto del giocatore", async () => {
const res = await rest("profili_giocatore", tokenAdmin, {
method: "POST",
body: JSON.stringify({ giocatore_id: GIOCATORE, data_nascita: "1999-01-01" }),
});
assert.ok(res.ok, `salvataggio profilo da admin: ${res.status} ${await res.text()}`);
assert.equal(await leggiNascita(tokenGiocatore), "1999-01-01");
});
} finally {
// Con la service role questa DELETE fallirebbe: fa scattare il trigger di sync, che
// tenta un UPDATE su giocatori_squadra ancora collegato, e `enforce_giocatori_squadra_update`
// rifiuta la service role quanto un utente qualsiasi (auth.uid() NULL, nessun ramo passa).
// Serve il JWT vero dell'admin, come per la cancellazione dentro il test.
if (tokenAdmin) {
await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, tokenAdmin, {
method: "DELETE",
});
} else {
await rest(`profili_giocatore?giocatore_id=eq.${GIOCATORE}`, SERVIZIO, { method: "DELETE" });
}
if (tokenAdmin) {
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: null }),
});
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("nascita-pubblica");
}
}
+483
View File
@@ -0,0 +1,483 @@
/**
* Centro notifiche in-app (M17): `bun test/integration/notifiche-utente.test.ts`.
*
* Copre le quattro sorgenti che scrivono in `notifiche_utente` mai dal client (le due
* funzioni `pg_cron` dei promemoria evento, la route `notifica-personalizzata`, la route
* `promemoria-palloni`, la route `sollecita-presenze`), più la RLS che decide chi può
* leggerle e segnarle come lette. `notifiche-utente.test.ts` (unit) copre la sola
* conversione riga->modello e il badge; qui serve un database vero per funzioni, trigger
* e policy scritte su `auth.uid()`.
*
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
* `test-notifiche-utente` per gli eventi e lo slot `g4` della rosa seed per la RLS,
* ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { avviaServer } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche in-app", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-utente");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche in-app su ${URL_BASE}`);
// Il server di sviluppo (per le sezioni con le route) eredita queste: le route leggono
// i nomi senza prefisso e senza questo override punterebbero al progetto cloud di `.env`.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const PREFISSO = "test-notifiche-utente";
const EVENTO_24H = `${PREFISSO}-24h`;
const EVENTO_3H = `${PREFISSO}-3h`;
const EVENTO_ORA_VALIDA = `${PREFISSO}-ora-valida`;
const EVENTO_ORA_ROTTA = `${PREFISSO}-ora-rotta`;
const EVENTO_PALLONI = `${PREFISSO}-palloni`;
const EVENTO_PRESENZE = `${PREFISSO}-presenze`;
const GIOCATORE_RLS = "g4";
const MESSAGGIO_UNICO = `${PREFISSO}-messaggio-${Date.now()}`;
/**
* `data`/`ora` sono ora locale Italia (stessa convenzione del resto dell'app, vedi
* `m10_azzera_turni_palloni_allenamenti` e la funzione `genera_promemoria_eventi`):
* calcolarle da `Date` in UTC senza passare da `Europe/Rome` sfaserebbe il confronto
* con `now()` lato database fino a due ore, abbastanza da far scattare il promemoria
* sbagliato vicino ai bordi delle finestre di 3 e 24 ore.
*/
function dataOraRoma(quando: Date): { data: string; ora: string } {
const data = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(quando);
const ora = new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(quando);
return { data, ora };
}
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function inserisci(tabella: string, riga: Record<string, unknown>) {
const res = await rest(tabella, SERVIZIO, {
method: "POST",
headers: { Prefer: "return=minimal" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
}
async function leggi<T>(percorso: string): Promise<T[]> {
const res = await rest(percorso, SERVIZIO);
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
return (await res.json()) as T[];
}
async function contaAttivi(): Promise<number> {
const righe = await leggi<{ id: string }>("giocatori_squadra?attivo=eq.true&select=id");
return righe.length;
}
async function chiamaRpc(nome: string, corpo: Record<string, unknown>) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/${nome}`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify(corpo),
});
if (!res.ok) throw new Error(`rpc ${nome}: ${res.status} ${await res.text()}`);
}
async function creaUtente(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
const PASSWORD = "prova-notifiche-utente-123";
const idUtenti: string[] = [];
let tokenAdmin = "";
let server: { baseUrl: string; stop: () => void } | null = null;
try {
// --- preparazione: un amministratore, un giocatore collegato a g4 -----------------
const emailAdmin = `test-notifiche-utente-admin-${Date.now()}@example.test`;
const emailGiocatore = `test-notifiche-utente-giocatore-${Date.now()}@example.test`;
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
idUtenti.push(idAdmin, idGiocatore);
await rest("user_roles", SERVIZIO, {
method: "POST",
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
tokenAdmin = await accedi(emailAdmin, PASSWORD);
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
// Il trigger `enforce_giocatori_squadra_update` rifiuta la service key (auth.uid() NULL):
// serve il JWT dell'admin, come in `permessi.test.ts`.
const collega = (utente: string | null) =>
rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: utente }),
});
await collega(idGiocatore);
// --- 1. Promemoria: finestre 24h/3h e deduplica -----------------------------------
await prova("il promemoria 24h scatta per un evento a 20 ore, non il 3h", async () => {
const { data, ora } = dataOraRoma(new Date(Date.now() + 20 * 60 * 60 * 1000));
await inserisci("eventi_app", {
id: EVENTO_24H,
tipo: "allenamento",
titolo: "Allenamento imminente",
data,
ora,
convocati: [GIOCATORE_RLS],
});
await chiamaRpc("genera_promemoria_eventi", {
p_tipo: "evento_promemoria_24h",
p_finestra: "24 hours",
});
await chiamaRpc("genera_promemoria_eventi", {
p_tipo: "evento_promemoria_3h",
p_finestra: "3 hours",
});
const promemoria24h = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_24h&select=id`,
);
const promemoria3h = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_24H}&tipo=eq.evento_promemoria_3h&select=id`,
);
assert.equal(promemoria24h.length, 1, "il promemoria 24h è stato generato");
assert.equal(
promemoria3h.length,
0,
"il promemoria 3h non scatta ancora a 20 ore di distanza",
);
});
await prova(
"il promemoria 3h scatta per un evento a 2 ore, ed entrambi non duplicano",
async () => {
const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
await inserisci("eventi_app", {
id: EVENTO_3H,
tipo: "allenamento",
titolo: "Allenamento imminentissimo",
data,
ora,
convocati: [GIOCATORE_RLS],
});
for (let i = 0; i < 2; i += 1) {
await chiamaRpc("genera_promemoria_eventi", {
p_tipo: "evento_promemoria_24h",
p_finestra: "24 hours",
});
await chiamaRpc("genera_promemoria_eventi", {
p_tipo: "evento_promemoria_3h",
p_finestra: "3 hours",
});
}
const promemoria24h = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_24h&select=id`,
);
const promemoria3h = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_3H}&tipo=eq.evento_promemoria_3h&select=id`,
);
assert.equal(promemoria24h.length, 1, "un solo promemoria 24h anche a due esecuzioni");
assert.equal(promemoria3h.length, 1, "un solo promemoria 3h anche a due esecuzioni");
},
);
// `ora` è testo libero (input nativo `type="time"`, ma la form non impedisce di
// svuotarlo): un evento con l'ora scritta male non deve rompere la generazione dei
// promemoria per tutti gli altri (era il caso prima che `ora_evento_a_time()`
// assorbisse l'errore riga per riga invece di far fallire l'intera INSERT...SELECT).
await prova(
"un evento con l'ora scritta male non blocca i promemoria degli altri eventi",
async () => {
const { data, ora } = dataOraRoma(new Date(Date.now() + 2 * 60 * 60 * 1000));
await inserisci("eventi_app", {
id: EVENTO_ORA_VALIDA,
tipo: "allenamento",
titolo: "Allenamento imminentissimo",
data,
ora,
convocati: [GIOCATORE_RLS],
});
await inserisci("eventi_app", {
id: EVENTO_ORA_ROTTA,
tipo: "allenamento",
titolo: "Evento con ora vuota",
data,
ora: "",
convocati: [GIOCATORE_RLS],
});
await chiamaRpc("genera_promemoria_eventi", {
p_tipo: "evento_promemoria_3h",
p_finestra: "3 hours",
});
const promemoria3h = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_ORA_VALIDA}&tipo=eq.evento_promemoria_3h&select=id`,
);
assert.equal(
promemoria3h.length,
1,
"l'evento con l'ora valida riceve comunque il suo promemoria",
);
},
);
// --- 2. RLS: ognuno legge e segna come lette solo le proprie ----------------------
await prova("il giocatore legge solo le proprie notifiche", async () => {
const mie = await rest(
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id`,
tokenGiocatore,
);
assert.equal(mie.status, 200);
const righeMie = (await mie.json()) as unknown[];
assert.ok(righeMie.length > 0, "g4 ha almeno una notifica dai test precedenti");
const altrui = await rest("notifiche_utente?giocatore_id=eq.g1&select=id", tokenGiocatore);
assert.equal(altrui.status, 200);
assert.deepEqual(
await altrui.json(),
[],
"le notifiche di un altro giocatore restano invisibili",
);
});
await prova("il giocatore segna come lette solo le proprie notifiche", async () => {
const res = await rest(
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false`,
tokenGiocatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ letta: true }),
},
);
assert.equal(res.status, 200);
const aggiornate = (await res.json()) as Array<{ letta: boolean }>;
assert.ok(aggiornate.length > 0);
assert.ok(aggiornate.every((r) => r.letta === true));
const restaFalse = await leggi(
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&letta=eq.false&select=id`,
);
assert.equal(restaFalse.length, 0, "nessuna non letta è rimasta indietro");
});
await prova("il client non può inserire notifiche per sé", async () => {
const res = await rest("notifiche_utente", tokenGiocatore, {
method: "POST",
body: JSON.stringify({ giocatore_id: GIOCATORE_RLS, tipo: "admin", titolo: "finta" }),
});
assert.ok(!res.ok, `l'insert dal client deve fallire, invece ha risposto ${res.status}`);
});
// --- 3. Route notifica-personalizzata: scrive anche lo storico in-app -------------
server = await avviaServer();
console.log(`route notifica-personalizzata su ${server.baseUrl}`);
await prova("un messaggio a un giocatore specifico crea solo la sua notifica", async () => {
const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
body: JSON.stringify({ messaggio: MESSAGGIO_UNICO, giocatoreId: GIOCATORE_RLS }),
});
assert.equal(res.status, 200);
const righe = await leggi<{ giocatore_id: string; tipo: string }>(
`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&select=giocatore_id,tipo`,
);
assert.deepEqual(righe, [{ giocatore_id: GIOCATORE_RLS, tipo: "admin" }]);
await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
method: "DELETE",
});
});
await prova("un messaggio senza destinatario notifica tutta la rosa attiva", async () => {
const attesi = await contaAttivi();
const res = await fetch(`${server!.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
body: JSON.stringify({ messaggio: MESSAGGIO_UNICO }),
});
assert.equal(res.status, 200);
const righe = await leggi(
`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}&tipo=eq.admin&select=giocatore_id`,
);
assert.equal(righe.length, attesi, "una notifica admin per ogni giocatore attivo");
});
await prova("il giocatore elimina solo le proprie notifiche", async () => {
const [miaNotifica] = await leggi<{ id: string }>(
`notifiche_utente?giocatore_id=eq.${GIOCATORE_RLS}&select=id&limit=1`,
);
const [notificaAltrui] = await leggi<{ id: string }>(
"notifiche_utente?giocatore_id=eq.g1&select=id&limit=1",
);
assert.ok(miaNotifica, "g4 ha almeno una notifica da eliminare");
assert.ok(notificaAltrui, "g1 ha almeno una notifica altrui da provare a eliminare");
// Lo swipe/la × chiamano una DELETE per id: qui la RLS deve bloccare quella
// sull'id di un altro giocatore (0 righe toccate) e permettere la propria.
await rest(`notifiche_utente?id=eq.${notificaAltrui.id}`, tokenGiocatore, {
method: "DELETE",
});
const altruiRestaAncora = await leggi(
`notifiche_utente?id=eq.${notificaAltrui.id}&select=id`,
);
assert.equal(altruiRestaAncora.length, 1, "la notifica di un altro giocatore non si tocca");
await rest(`notifiche_utente?id=eq.${miaNotifica.id}`, tokenGiocatore, {
method: "DELETE",
});
const miaResta = await leggi(`notifiche_utente?id=eq.${miaNotifica.id}&select=id`);
assert.equal(miaResta.length, 0, "la propria notifica è stata eliminata");
});
// --- 4. Route promemoria-palloni: scrive anche lo storico in-app ------------------
await prova("il promemoria del turno palloni crea la notifica per l'incaricato", async () => {
await inserisci("eventi_app", {
id: EVENTO_PALLONI,
tipo: "partita",
titolo: "Partita con palloni assegnati",
data: "2026-09-25",
ora: "18:00",
convocati: [],
});
await inserisci("turni_palloni", { evento_id: EVENTO_PALLONI, giocatore_id: "g1" });
const res = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
});
assert.equal(res.status, 200);
const righe = await leggi<{ giocatore_id: string; letta: boolean }>(
`notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=giocatore_id,letta`,
);
assert.equal(righe.length, 1, "una sola notifica per l'incaricato");
assert.equal(righe[0]?.letta, false);
// Ripremere il pulsante aggiorna la stessa riga (upsert) invece di duplicarla o
// fallire per il vincolo UNIQUE.
const res2 = await fetch(`${server!.baseUrl}/api/public/promemoria-palloni`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
body: JSON.stringify({ eventoId: EVENTO_PALLONI }),
});
assert.equal(res2.status, 200);
const dopo = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_PALLONI}&tipo=eq.turno_palloni&giocatore_id=eq.g1&select=id`,
);
assert.equal(dopo.length, 1, "un secondo invio non duplica la notifica");
});
// --- 5. Route sollecita-presenze: scrive anche lo storico in-app ------------------
await prova("il sollecito presenze notifica chi non ha ancora risposto", async () => {
const attesi = await contaAttivi();
await inserisci("eventi_app", {
id: EVENTO_PRESENZE,
tipo: "allenamento",
titolo: "Allenamento da confermare",
data: "2026-09-26",
ora: "20:30",
convocati: [],
});
const res = await fetch(`${server!.baseUrl}/api/public/sollecita-presenze`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${tokenAdmin}` },
body: JSON.stringify({ eventoId: EVENTO_PRESENZE }),
});
assert.equal(res.status, 200);
const righe = await leggi(
`notifiche_utente?evento_id=eq.${EVENTO_PRESENZE}&tipo=eq.sollecita_presenze&select=giocatore_id`,
);
assert.equal(righe.length, attesi, "nessuno ha ancora risposto: tutta la rosa attiva");
});
} finally {
server?.stop();
await rest(`notifiche_utente?corpo=eq.${encodeURIComponent(MESSAGGIO_UNICO)}`, SERVIZIO, {
method: "DELETE",
});
await rest(`turni_palloni?evento_id=eq.${EVENTO_PALLONI}`, SERVIZIO, { method: "DELETE" });
for (const id of [
EVENTO_24H,
EVENTO_3H,
EVENTO_ORA_VALIDA,
EVENTO_ORA_ROTTA,
EVENTO_PALLONI,
EVENTO_PRESENZE,
]) {
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
if (tokenAdmin) {
await rest(`giocatori_squadra?id=eq.${GIOCATORE_RLS}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: null }),
});
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("notifiche-utente");
}
}
@@ -0,0 +1,340 @@
/**
* Permessi del ruolo allenatore (DD-034, migration M21): `bun test/integration/permessi-allenatore.test.ts`.
*
* Stesso impianto di `permessi.test.ts`: utenti veri, database interrogato *come loro*,
* solo contro lo stack locale. L'allenatore è uno slot di `giocatori_squadra` con
* `tipo = 'allenatore'`, creato dall'admin e reclamato per email al primo accesso.
*
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("permessi allenatore", "stack locale non attivo (npx supabase start)");
riepilogo("permessi-allenatore");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`permessi allenatore su ${URL_BASE}`);
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const righeToccate = async (res: Response): Promise<number> => {
if (!res.ok) return 0;
const corpo = (await res.json()) as unknown[];
return Array.isArray(corpo) ? corpo.length : 0;
};
async function creaUtente(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
return corpo.id;
}
async function accedi(email: string, password: string): Promise<string> {
const res = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
const corpo = (await res.json()) as { access_token?: string };
if (!corpo.access_token) throw new Error(`accesso fallito: ${JSON.stringify(corpo)}`);
return corpo.access_token;
}
const eliminaUtente = (id: string) =>
fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
const ruoliDi = async (idUtente: string): Promise<string[]> => {
const res = await rest(`user_roles?user_id=eq.${idUtente}&select=role`, SERVIZIO);
return ((await res.json()) as Array<{ role: string }>).map((r) => r.role);
};
const PASSWORD = "prova-allenatore-123";
const PREFISSO = "test-allenatore";
const SLOT = "g9001";
const GIOCATORE = "g3";
const EVENTO = `${PREFISSO}-evento`;
const idUtenti: string[] = [];
let tokenAdmin = "";
try {
const oggi = Date.now();
const emailAllenatore = `test-allenatore-${oggi}@example.test`;
const emailAdmin = `test-admin-all-${oggi}@example.test`;
const emailGiocatore = `test-giocatore-all-${oggi}@example.test`;
const idAllenatore = await creaUtente(emailAllenatore, PASSWORD);
const idAdmin = await creaUtente(emailAdmin, PASSWORD);
const idGiocatore = await creaUtente(emailGiocatore, PASSWORD);
idUtenti.push(idAllenatore, idAdmin, idGiocatore);
await rest("user_roles", SERVIZIO, {
method: "POST",
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
tokenAdmin = await accedi(emailAdmin, PASSWORD);
const tokenAllenatore = await accedi(emailAllenatore, PASSWORD);
const tokenGiocatore = await accedi(emailGiocatore, PASSWORD);
const collegaGiocatore = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idGiocatore }),
});
assert.equal(await righeToccate(collegaGiocatore), 1, `${GIOCATORE} collegato al test`);
await prova("l'admin registra un allenatore senza numero né ruolo", async () => {
const res = await rest("giocatori_squadra", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: SLOT,
nome: "Carla",
cognome: "Neri",
numero: null,
ruolo: "",
email: emailAllenatore,
tipo: "allenatore",
}),
});
assert.equal(await righeToccate(res), 1, `slot allenatore creato (${res.status})`);
});
await prova("un giocatore senza numero resta rifiutato", async () => {
const res = await rest("giocatori_squadra", tokenAdmin, {
method: "POST",
body: JSON.stringify({ id: "g9002", nome: "X", cognome: "Y", numero: null, ruolo: "" }),
});
assert.ok(!res.ok, `il numero è obbligatorio per chi gioca (${res.status})`);
});
await prova("il collegamento per email dà il ruolo allenatore", async () => {
assert.deepEqual(await ruoliDi(idAllenatore), [], "prima del collegamento nessun ruolo");
const res = await rest(
`giocatori_squadra?id=eq.${SLOT}&auth_user_id=is.null`,
tokenAllenatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: idAllenatore }),
},
);
assert.equal(await righeToccate(res), 1, `slot reclamato (${res.status})`);
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"]);
});
await prova("l'allenatore crea, modifica ed elimina gli eventi", async () => {
const crea = await rest("eventi_app", tokenAllenatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO,
tipo: "partita",
titolo: "Partita di prova",
data: "2026-01-01",
ora: "20:00",
luogo: "Palestra",
convocati: [GIOCATORE],
}),
});
assert.equal(await righeToccate(crea), 1, `evento creato (${crea.status})`);
const modifica = await rest(`eventi_app?id=eq.${EVENTO}`, tokenAllenatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ luogo: "Altra palestra" }),
});
assert.equal(await righeToccate(modifica), 1, "evento modificato");
const altro = await rest("eventi_app", tokenAllenatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: `${PREFISSO}-da-cancellare`,
tipo: "allenamento",
titolo: "Da cancellare",
data: "2026-01-02",
ora: "20:00",
luogo: "",
}),
});
assert.equal(await righeToccate(altro), 1);
const cancella = await rest(`eventi_app?id=eq.${PREFISSO}-da-cancellare`, tokenAllenatore, {
method: "DELETE",
headers: { Prefer: "return=representation" },
});
assert.equal(await righeToccate(cancella), 1, "evento eliminato");
});
await prova("il giocatore continua a non gestire gli eventi", async () => {
const res = await rest(`eventi_app?id=eq.${EVENTO}`, tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ luogo: "Abusivo" }),
});
assert.equal(await righeToccate(res), 0);
});
await prova("l'allenatore cambia il proprio nome e cognome", async () => {
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ nome: "Carlotta", cognome: "Neri Bianchi" }),
});
assert.equal(await righeToccate(res), 1, `nome aggiornato (${res.status})`);
});
await prova("l'allenatore non cambia il proprio tipo né gli altri campi admin", async () => {
for (const campi of [{ tipo: "giocatore" }, { numero: 5 }, { email: "altra@example.test" }]) {
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAllenatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify(campi),
});
assert.equal(await righeToccate(res), 0, `rifiutato: ${JSON.stringify(campi)}`);
}
assert.deepEqual(await ruoliDi(idAllenatore), ["allenatore"], "il ruolo resta");
});
await prova("l'allenatore non scrive lo slot di un giocatore", async () => {
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAllenatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ nome: "Abusivo" }),
});
assert.equal(await righeToccate(res), 0);
});
await prova("il giocatore non cambia il proprio nome", async () => {
const res = await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenGiocatore, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ nome: "Abusivo" }),
});
assert.equal(await righeToccate(res), 0, "per chi gioca nome e cognome restano all'admin");
});
await prova("l'allenatore non risponde alle presenze né dichiara cacche", async () => {
const presenza = await rest("risposte_presenze", tokenAllenatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, stato: "presente" }),
});
assert.ok(!presenza.ok, `presenza rifiutata (${presenza.status})`);
const cacche = await rest("cacche_partita", tokenAllenatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: SLOT, quantita: 1 }),
});
assert.ok(!cacche.ok, `cacche rifiutate (${cacche.status})`);
});
await prova("l'allenatore non vota e non viene votato", async () => {
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ convocati: [] }),
});
const vota = await rest("mvp_voti", tokenAllenatore, {
method: "POST",
body: JSON.stringify({
match_id: EVENTO,
votante_id: SLOT,
votato_id: GIOCATORE,
votato_nome: "Tre",
}),
});
assert.ok(!vota.ok, `voto dell'allenatore rifiutato (${vota.status})`);
const votato = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
body: JSON.stringify({
match_id: EVENTO,
votante_id: GIOCATORE,
votato_id: SLOT,
votato_nome: "Allenatore",
}),
});
assert.ok(!votato.ok, `l'allenatore non si vota (${votato.status})`);
});
await prova("i promemoria evento arrivano anche all'allenatore non convocato", async () => {
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ convocati: [GIOCATORE] }),
});
const res = await rest("rpc/giocatori_destinatari_evento", SERVIZIO, {
method: "POST",
body: JSON.stringify({ p_evento_id: EVENTO }),
});
assert.equal(res.status, 200);
const destinatari = ((await res.json()) as Array<string | Record<string, string>>).map((d) =>
typeof d === "string" ? d : Object.values(d)[0],
);
assert.deepEqual([...destinatari].sort(), [GIOCATORE, SLOT].sort());
});
await prova("scollegare l'account toglie il ruolo", async () => {
const res = await rest(`giocatori_squadra?id=eq.${SLOT}`, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ auth_user_id: null }),
});
assert.equal(await righeToccate(res), 1);
assert.deepEqual(await ruoliDi(idAllenatore), []);
const crea = await rest("eventi_app", tokenAllenatore, {
method: "POST",
body: JSON.stringify({
id: `${PREFISSO}-dopo`,
tipo: "allenamento",
titolo: "Non deve esistere",
data: "2026-01-03",
ora: "20:00",
luogo: "",
}),
});
assert.ok(!crea.ok, `senza ruolo niente eventi (${crea.status})`);
});
} finally {
await rest(`mvp_voti?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(`giocatori_squadra?id=in.(${SLOT},g9002)`, SERVIZIO, { method: "DELETE" });
if (tokenAdmin) {
await rest(`giocatori_squadra?id=eq.${GIOCATORE}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify({ auth_user_id: null }),
});
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await eliminaUtente(id);
}
riepilogo("permessi-allenatore");
}
}
+61
View File
@@ -66,8 +66,19 @@ if (!locale) {
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
// Il ruolo allenatore lo scrive il trigger di M21 al collegamento dello slot; qui basta
// la riga in `user_roles`, che è ciò che la route legge (DD-034).
const emailAllenatore = `test-route-allenatore-${Date.now()}@example.test`;
const idAllenatore = await creaUtente(emailAllenatore);
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ user_id: idAllenatore, role: "allenatore" }),
});
const tokenGiocatore = await accedi(emailGiocatore);
const tokenAdmin = await accedi(emailAdmin);
const tokenAllenatore = await accedi(emailAllenatore);
const server = await avviaServer();
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
@@ -107,8 +118,58 @@ if (!locale) {
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
}
});
await prova(
"l'allenatore sollecita le presenze ma non manda palloni né sondaggio",
async () => {
const sollecito = await chiama("/api/public/sollecita-presenze", {
authorization: `Bearer ${tokenAllenatore}`,
});
assert.equal(sollecito.status, 404, "sollecito: superato l'accesso, evento inesistente");
for (const percorso of ["/api/public/apri-sondaggio", "/api/public/promemoria-palloni"]) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenAllenatore}` });
assert.equal(res.status, 403, `${percorso}: restano all'admin`);
}
},
);
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", ...intestazioni },
body: JSON.stringify({ messaggio: "prova" }),
});
await prova("senza token non si manda un messaggio personalizzato", async () => {
assert.equal((await chiamaMessaggio()).status, 401);
});
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403);
});
await prova("l'allenatore non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAllenatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
const corpo = (await res.json()) as { inviate: number; destinatari: number };
assert.equal(corpo.destinatari, 0);
assert.equal(corpo.inviate, 0);
});
} finally {
server.stop();
// La route scrive anche lo storico in-app (M17): il messaggio "prova" a tutta la
// rosa lascia righe in `notifiche_utente` che altrimenti restano per sempre.
await fetch(`${SUPABASE}/rest/v1/notifiche_utente?corpo=eq.prova&tipo=eq.admin`, {
method: "DELETE",
headers: authAdmin,
});
for (const id of idUtenti) {
await fetch(`${SUPABASE}/rest/v1/user_roles?user_id=eq.${id}`, {
method: "DELETE",
+5 -3
View File
@@ -27,6 +27,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
@@ -97,16 +99,16 @@ if (!locale) {
}
/** Rilegge esattamente come `fetchPresenze()`. */
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
async function leggiPresenze(): Promise<MappaPresenze> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
stato: Stato;
}>;
const mappa: Record<string, Record<string, string>> = {};
const mappa: MappaPresenze = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
+5 -3
View File
@@ -20,6 +20,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
@@ -87,16 +89,16 @@ if (!locale) {
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
async function leggiPresenze(): Promise<MappaPresenze> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
stato: Stato;
}>;
const mappa: Record<string, Record<string, string>> = {};
const mappa: MappaPresenze = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
+5 -3
View File
@@ -19,6 +19,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
@@ -90,7 +92,7 @@ if (!locale) {
}
async function leggiPresenzeETempi(): Promise<{
presenze: Record<string, Record<string, string>>;
presenze: MappaPresenze;
tempi: Record<string, Record<string, string>>;
}> {
const res = await rest(
@@ -99,10 +101,10 @@ if (!locale) {
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
stato: Stato;
risposto_il: string;
}>;
const presenze: Record<string, Record<string, string>> = {};
const presenze: MappaPresenze = {};
const tempi: Record<string, Record<string, string>> = {};
for (const r of righe) {
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
+5 -3
View File
@@ -17,6 +17,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
@@ -81,16 +83,16 @@ if (!locale) {
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
async function leggiPresenze(): Promise<MappaPresenze> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
stato: Stato;
}>;
const mappa: Record<string, Record<string, string>> = {};
const mappa: MappaPresenze = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
+9 -3
View File
@@ -59,6 +59,14 @@ if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
// Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST
// risponde comunque 2xx. Quello che conta è che il dato non cambi.
await prova("un anonimo non si collega a uno slot della rosa", async () => {
const leggiSlot = async () => {
const r = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
const righe = (await r.json()) as Array<{ auth_user_id: string | null }>;
return righe[0]?.auth_user_id ?? null;
};
// Sul database di produzione g1 può essere già collegato a un giocatore vero:
// conta che il tentativo anonimo non cambi il valore, non che lo slot sia libero.
const prima = await leggiSlot();
const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, {
method: "PATCH",
headers: { Prefer: "return=representation" },
@@ -68,9 +76,7 @@ if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
const aggiornate = (await res.json()) as unknown[];
assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione");
}
const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero");
assert.equal(await leggiSlot(), prima, "lo slot g1 non è stato modificato");
});
// --- M8: tracciamento tesseramento CSI --------------------------------------
@@ -26,6 +26,8 @@ import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
@@ -93,16 +95,16 @@ if (!locale) {
return righe.map(daRiga);
}
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
async function leggiPresenze(): Promise<MappaPresenze> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: string;
stato: Stato;
}>;
const mappa: Record<string, Record<string, string>> = {};
const mappa: MappaPresenze = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
return mappa;
}
+70
View File
@@ -0,0 +1,70 @@
/** Logica pura del backup storage: `bun test/unit/backup-storage.test.ts`. Solo dati finti. */
import assert from "node:assert/strict";
import { elencaFile, leggiEnv, type Voce } from "../../backups/storage-core.ts";
import { prova, riepilogo } from "../helpers/prova";
// --- leggiEnv -----------------------------------------------------------------
await prova("toglie virgolette doppie e singole", () => {
const env = leggiEnv(`A="uno"\nB='due'\nC=tre`);
assert.deepEqual(env, { A: "uno", B: "due", C: "tre" });
});
await prova("lascia le virgolette che non fanno coppia", () => {
const env = leggiEnv(`A="aperta\nB=chiusa"\nC="mista'`);
assert.deepEqual(env, { A: '"aperta', B: 'chiusa"', C: `"mista'` });
});
await prova("conserva gli = dentro il valore", () => {
assert.equal(leggiEnv(`CHIAVE="abc=="`)["CHIAVE"], "abc==");
});
await prova("ignora righe vuote, commenti e spazi ai bordi", () => {
const env = leggiEnv(`# commento\n\n A=1 \r\nnon una variabile\n`);
assert.deepEqual(env, { A: "1" });
});
await prova("valore vuoto resta stringa vuota", () => {
assert.deepEqual(leggiEnv(`A=\nB=""`), { A: "", B: "" });
});
// --- elencaFile ---------------------------------------------------------------
const cartella = (name: string): Voce => ({ name, id: null });
const file = (name: string): Voce => ({ name, id: `id-${name}` });
function bucketFinto(albero: Record<string, Voce[]>) {
const chiesti: string[] = [];
const lista = async (prefisso: string) => {
chiesti.push(prefisso);
return albero[prefisso] ?? [];
};
return { lista, chiesti };
}
await prova("scende nelle sottocartelle e restituisce percorsi completi", async () => {
const { lista } = bucketFinto({
"": [cartella("g1"), file("radice.jpg")],
g1: [file("avatar.jpg"), cartella("doc")],
"g1/doc": [file("fronte.png")],
});
assert.deepEqual(await elencaFile(lista), ["g1/avatar.jpg", "g1/doc/fronte.png", "radice.jpg"]);
});
await prova("interroga ogni cartella una volta sola", async () => {
const { lista, chiesti } = bucketFinto({ "": [cartella("a"), cartella("b")], a: [file("x")] });
await elencaFile(lista);
assert.deepEqual(chiesti, ["", "a", "b"]);
});
await prova("bucket vuoto: nessun file", async () => {
assert.deepEqual(await elencaFile(bucketFinto({}).lista), []);
});
await prova("un errore di elenco interrompe il backup invece di saltare file", async () => {
const lista = async (prefisso: string) => {
if (prefisso === "g1") throw new Error("rete giù");
return [cartella("g1")];
};
await assert.rejects(elencaFile(lista), /rete giù/);
});
riepilogo("backup-storage");
+24
View File
@@ -0,0 +1,24 @@
/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */
import assert from "node:assert/strict";
import { giorniDelMese, pad2 } from "@/lib/calendario";
// --- pad2 --------------------------------------------------------------
assert.equal(pad2(1), "01");
assert.equal(pad2(9), "09");
assert.equal(pad2(10), "10");
assert.equal(pad2(31), "31");
// --- giorniDelMese -------------------------------------------------------
// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6.
assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 });
// Febbraio 2028 (bisestile): 29 giorni.
assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 });
// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1.
assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 });
// Gennaio 2027: il 1° gennaio è venerdì -> offset 4.
assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 });
console.log("calendario: ok");
+33
View File
@@ -10,6 +10,9 @@ import {
parseClassifica,
parseFormazioni,
parseInfoPartita,
raggruppaPerStagione,
stagioneDaPartite,
stagioneDi,
parsePrecedenti,
partiteDaEventi,
partiteFormatoSospetto,
@@ -368,4 +371,34 @@ if (process.env["CSI_LIVE"]) {
);
}
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
assert.equal(stagioneDi("2025-11-12"), "2025/26");
assert.equal(stagioneDi("2026-05-20"), "2025/26");
assert.equal(stagioneDi("2026-07-31"), "2025/26");
assert.equal(stagioneDi("2026-08-01"), "2026/27");
assert.equal(stagioneDi("2099-10-01"), "2099/00");
assert.equal(stagioneDaPartite([]), null);
assert.equal(
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
"2025/26",
);
assert.deepEqual(
raggruppaPerStagione([
{ id: "a", data: "2026-04-10" },
{ id: "b", data: "2025-10-01" },
{ id: "c", data: "2025-05-02" },
]),
[
{
stagione: "2025/26",
match: [
{ id: "a", data: "2026-04-10" },
{ id: "b", data: "2025-10-01" },
],
},
{ stagione: "2024/25", match: [{ id: "c", data: "2025-05-02" }] },
],
);
console.log("csi-core: ok");
+70 -1
View File
@@ -1,10 +1,14 @@
/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */
import assert from "node:assert/strict";
import {
daRigaSquadra,
inRosa,
isAllenatore,
nomeCompleto,
numeroGiaUsato,
prossimoIdGiocatore,
rosaFallback,
ruoloVisibile,
slotDi,
slotPerEmail,
validaDatiSquadra,
@@ -23,6 +27,8 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
email: null,
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
...parziale,
});
@@ -47,6 +53,11 @@ assert.deepEqual(
giocatori.map((g) => g.id).sort(),
"gli id combaciano con la rosa reale",
);
assert.deepEqual(
Object.fromEntries(fallback.map((g) => [g.id, g.nascita])),
Object.fromEntries(giocatori.map((g) => [g.id, g.nascita])),
"la nascita del fallback combacia con quella del seed (M18)",
);
// --- nomeCompleto ----------------------------------------------------------------
assert.equal(nomeCompleto(riga()), "Mario Rossi");
@@ -86,7 +97,14 @@ assert.equal(
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
// --- validaDatiSquadra ----------------------------------------------------------------
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null };
const datiOk = {
nome: "Mario",
cognome: "Rossi",
numero: 7,
ruolo: "schiacciatore",
email: null,
tipo: "giocatore" as const,
};
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
assert.match(
@@ -149,5 +167,56 @@ assert.equal(
"conta solo il 7 attivo di g1, non serve escludere g3",
);
assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato");
assert.equal(
numeroGiaUsato([riga({ id: "g5", numero: 0, tipo: "allenatore" })], "g4", 0),
false,
"l'allenatore non occupa numeri di maglia",
);
// --- allenatore (DD-034) ---------------------------------------------------------
const allenatore = riga({ id: "g20", numero: 0, ruolo: "", tipo: "allenatore" });
assert.equal(isAllenatore(allenatore), true);
assert.equal(isAllenatore(riga()), false);
assert.equal(isAllenatore(null), false, "senza slot non si è allenatori");
assert.equal(inRosa(riga()), true, "giocatore attivo: in rosa");
assert.equal(inRosa(riga({ attivo: false })), false, "giocatore disattivato: fuori");
assert.equal(inRosa(allenatore), false, "l'allenatore non è nella rosa di gioco");
assert.equal(ruoloVisibile(allenatore), "Allenatore", "dove c'è il ruolo in campo");
assert.equal(ruoloVisibile(riga({ ruolo: "Libero" })), "Libero");
assert.equal(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", numero: 0, ruolo: "" }),
null,
"l'allenatore non ha numero né ruolo",
);
assert.match(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", nome: " " })!,
/nome/i,
"il nome resta obbligatorio anche per l'allenatore",
);
assert.match(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", email: "senza-chiocciola" })!,
/email/i,
);
const rigaDb = {
id: "g20",
nome: "Carla",
cognome: "Neri",
numero: null,
ruolo: "",
auth_user_id: null,
attivo: true,
email: "carla@gmail.com",
numero_tessera: null,
data_tessera: null,
nascita: null,
tipo: "allenatore" as const,
};
assert.equal(daRigaSquadra(rigaDb).numero, 0, "numero NULL a database diventa 0");
assert.equal(daRigaSquadra(rigaDb).tipo, "allenatore");
assert.equal(daRigaSquadra({ ...rigaDb, numero: 7, tipo: "giocatore" }).numero, 7);
assert.ok(
rosaFallback().every((g) => g.tipo === "giocatore"),
"il fallback contiene solo giocatori",
);
console.log("giocatori-squadra: ok");
+51
View File
@@ -0,0 +1,51 @@
/** Check delle notifiche in-app (M17): `bun test/unit/notifiche-utente.test.ts`. */
import assert from "node:assert/strict";
import {
contatoreBadge,
daRiga,
pallinoNotifiche,
type RigaNotifica,
} from "@/lib/notifiche-utente";
// --- daRiga: conversione database -> modello applicativo ---------------------
const riga: RigaNotifica = {
id: "n1",
tipo: "evento_promemoria_24h",
titolo: "Promemoria: Allenamento",
corpo: "10/09/2026 alle 20:30",
evento_id: "e1",
letta: false,
creato_il: "2026-09-10T09:00:00Z",
};
assert.deepEqual(daRiga(riga), {
id: "n1",
tipo: "evento_promemoria_24h",
titolo: "Promemoria: Allenamento",
corpo: "10/09/2026 alle 20:30",
eventoId: "e1",
letta: false,
creataIl: "2026-09-10T09:00:00Z",
});
assert.equal(
daRiga({ ...riga, evento_id: null }).eventoId,
null,
"un messaggio admin non ha evento",
);
// --- contatoreBadge: il numero esatto fino a 9, poi "9+" ---------------------
assert.equal(contatoreBadge(0), "0");
assert.equal(contatoreBadge(1), "1");
assert.equal(contatoreBadge(9), "9");
assert.equal(contatoreBadge(10), "9+");
assert.equal(contatoreBadge(42), "9+");
// --- pallinoNotifiche: rosso con le non lette, neutro col totale, assente se vuoto ---
assert.equal(pallinoNotifiche(0, 0), null);
assert.deepEqual(pallinoNotifiche(5, 2), { testo: "2", daLeggere: true });
assert.deepEqual(pallinoNotifiche(30, 12), { testo: "9+", daLeggere: true });
// Tutte lette: il pallino resta per poterle ancora aprire ed eliminare.
assert.deepEqual(pallinoNotifiche(3, 0), { testo: "3", daLeggere: false });
console.log("notifiche-utente: ok");
+6 -5
View File
@@ -120,11 +120,12 @@ assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è con
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
const squadra = [
{ id: "g1", attivo: true },
{ id: "g2", attivo: true },
{ id: "g3", attivo: true },
{ id: "g4", attivo: true },
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più
{ id: "g1", attivo: true, tipo: "giocatore" as const },
{ id: "g2", attivo: true, tipo: "giocatore" as const },
{ id: "g3", attivo: true, tipo: "giocatore" as const },
{ id: "g4", attivo: true, tipo: "giocatore" as const },
{ id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più
{ id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034)
];
const risposte = [
+152 -1
View File
@@ -2,11 +2,16 @@
import assert from "node:assert/strict";
import {
aRigaProfilo,
avvisiCertificati,
completamento,
completamentoAllenatore,
csvTesseramento,
daRigaProfilo,
formatDataBreve,
giorniAllaScadenza,
sezioniComplete,
statoScadenza,
testoScadenza,
type Profilo,
} from "@/lib/profili-core";
import {
@@ -114,6 +119,8 @@ const squadra: GiocatoreSquadra[] = [
email: null,
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
},
{
id: "g2",
@@ -126,6 +133,8 @@ const squadra: GiocatoreSquadra[] = [
email: null,
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
},
];
const csv = csvTesseramento(squadra, { g1: completo });
@@ -153,7 +162,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
assert.equal(slotDi(squadra, "sconosciuto"), null);
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null };
const datiOk = {
nome: "Ivan",
cognome: "Cacciari",
numero: 23,
ruolo: "Banda",
email: null,
tipo: "giocatore" as const,
};
assert.equal(validaDatiSquadra(datiOk), null);
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
@@ -182,4 +198,139 @@ assert.ok(
"il fallback non può collegare account",
);
// --- completamento dell'allenatore (DD-034) --------------------------------------
assert.equal(completamentoAllenatore(null), 0, "senza profilo: 0%");
assert.equal(
completamentoAllenatore({ ...vuoto, dataNascita: "1980-01-01", telefono: "333" }),
50,
"due campi su quattro",
);
assert.equal(
completamentoAllenatore({
...vuoto,
dataNascita: "1980-01-01",
luogoNascita: "Bologna",
telefono: "333",
email: "a@b.it",
}),
100,
"bastano i quattro dati personali: niente indirizzo, documento, certificato, foto",
);
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
// --- avviso certificati (DD-035) ------------------------------------------------
assert.equal(giorniAllaScadenza("2026-09-27", "2026-09-20"), 7);
assert.equal(giorniAllaScadenza("2026-09-19", "2026-09-20"), -1, "scaduto ieri");
assert.equal(giorniAllaScadenza("2026-09-20", "2026-09-20"), 0, "scade oggi");
assert.equal(giorniAllaScadenza("2026-10-01", "2026-09-30"), 1, "cambio di mese");
assert.equal(giorniAllaScadenza("2027-01-01", "2026-12-31"), 1, "cambio di anno");
assert.equal(formatDataBreve("2026-09-12"), "12/09/2026");
assert.equal(testoScadenza(0), "scade oggi");
assert.equal(testoScadenza(1), "scade domani");
assert.equal(testoScadenza(5), "scade tra 5 giorni");
const rosaCertificati: GiocatoreSquadra[] = [
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari", attivo: true, tipo: "giocatore" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni", attivo: true, tipo: "giocatore" },
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi", attivo: true, tipo: "giocatore" },
{ ...squadra[0]!, id: "g4", nome: "Sara", cognome: "Neri", attivo: false, tipo: "giocatore" },
{ ...squadra[0]!, id: "g5", nome: "Luca", cognome: "Bianchi", attivo: true, tipo: "allenatore" },
];
const oggiTest = "2026-09-20";
const profiliCertificati: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
};
const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
assert.deepEqual(
avvisi.scaduti.map((a) => a.giocatoreId),
["g2"],
);
assert.deepEqual(
avvisi.inScadenza.map((a) => a.giocatoreId),
["g1"],
);
assert.equal(
avvisiCertificati(
[{ ...squadra[0]!, id: "g6" }],
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
oggiTest,
).inScadenza.length,
0,
"certificato mancante: nessun avviso",
);
const dueInScadenza: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
};
const ordinati = avvisiCertificati(
[
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
],
dueInScadenza,
oggiTest,
).inScadenza.map((a) => a.giocatoreId);
assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
const pariGiorni: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
};
const alfabetico = avvisiCertificati(
[
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
],
pariGiorni,
oggiTest,
).inScadenza.map((a) => a.giocatoreId);
assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
const rosaSoglia: GiocatoreSquadra[] = [
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
];
const profiliSoglia: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
};
const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
assert.deepEqual(
soglia.inScadenza.map((a) => a.giocatoreId),
["g3", "g2"],
"8 giorni resta valido, 7 e 0 generano avviso",
);
assert.equal(soglia.scaduti.length, 0);
// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
const rosaScaduti: GiocatoreSquadra[] = [
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
];
const profiliScaduti: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
};
const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
(a) => a.giocatoreId,
);
assert.deepEqual(
scadutiOrdinati,
["g2", "g1", "g3"],
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
);
console.log("profili-core: ok");
+8 -3
View File
@@ -4,11 +4,16 @@ import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
const codice = readFileSync(new URL("../../public/push-sw.js", import.meta.url), "utf8");
type Evento = { data?: { json: () => unknown } | null; waitUntil: (p: Promise<unknown>) => void };
type Evento = {
data?: { json: () => unknown } | null | undefined;
waitUntil: (p: Promise<unknown>) => void;
};
// `renotify` è supportato da Chromium ma manca dai tipi DOM di TypeScript.
type OpzioniNotifica = NotificationOptions & { renotify?: boolean };
function avviaWorker() {
const listener = new Map<string, (event: Evento) => void>();
const notifiche: Array<{ titolo: string; opzioni: NotificationOptions }> = [];
const notifiche: Array<{ titolo: string; opzioni: OpzioniNotifica }> = [];
let completa: (() => void) | undefined;
const mostrata = new Promise<void>((resolve) => (completa = resolve));
const installata = Promise.resolve();
@@ -23,7 +28,7 @@ function avviaWorker() {
matchAll: () => assert.fail("la push deve funzionare senza consultare finestre aperte"),
},
registration: {
showNotification(titolo: string, opzioni: NotificationOptions) {
showNotification(titolo: string, opzioni: OpzioniNotifica) {
notifiche.push({ titolo, opzioni });
return mostrata;
},
+2 -1
View File
@@ -4,7 +4,8 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
const stato = statoIniziale("Avversari", true);
assert.deepEqual(stato, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
const trasferta = statoIniziale("Altra Squadra", false);
assert.equal(trasferta.casa, false);
+2 -3
View File
@@ -1,4 +1,4 @@
import { defineConfig, loadEnv, mergeConfig } from "vite";
import { defineConfig, loadEnv, mergeConfig, type UserConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import tsConfigPaths from "vite-tsconfig-paths";
import viteReact from "@vitejs/plugin-react";
@@ -46,14 +46,13 @@ export default defineConfig(async ({ command, mode }) => {
}
plugins.push(viteReact());
let config = {
let config: UserConfig = {
define: envDefine,
...(isDevBuild
? {
environments: {
client: { define: { "process.env.NODE_ENV": JSON.stringify("development") } },
},
esbuild: { keepNames: true },
}
: {}),
css: { transformer: "lightningcss" as const },