Compare commits
No files matched your search
@@ -7,7 +7,6 @@ Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolant
|
||||
per intero.
|
||||
|
||||
- Non implementare funzionalità non documentate in `docs/`.
|
||||
- Lavora su `develop`, mai direttamente su `main`.
|
||||
- Codice, commenti e documentazione in italiano.
|
||||
|
||||
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
|
||||
|
||||
@@ -11,17 +11,22 @@ usabile dallo smartphone anche da chi non è pratico.
|
||||
## Prima di modificare il codice
|
||||
|
||||
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
|
||||
il documento del modulo interessato in [docs/modules/](docs/modules/).
|
||||
il documento del modulo interessato in [docs/modules/](docs/modules/). Le decisioni
|
||||
([docs/decisions/](docs/decisions/README.md)) si leggono una per una, solo quelle che il modulo
|
||||
richiama o che l'indice indica per il tema: non servono tutte. Per i termini del dominio c'è il
|
||||
[glossario](docs/GLOSSARIO.md); le «Trappole note» in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) vanno lette prima di toccare codice o schema.
|
||||
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
|
||||
introdotto da altri collaboratori o da altri assistenti.
|
||||
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
|
||||
verità è il repository, non la cronologia della conversazione.
|
||||
|
||||
Non implementare funzionalità non documentate: prima si documenta
|
||||
([DD-002](docs/DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)), poi si scrive il codice.
|
||||
([DD-002](docs/decisions/DD-002.md)), poi si scrive il codice.
|
||||
|
||||
## Comandi
|
||||
|
||||
Variabili d'ambiente, ambienti e rilascio sono in [docs/OPERATIONS.md](docs/OPERATIONS.md).
|
||||
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
|
||||
@@ -82,9 +87,14 @@ Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiest
|
||||
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
|
||||
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
|
||||
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
|
||||
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
|
||||
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
|
||||
[PROJECT_STATE.md](PROJECT_STATE.md) (a che punto siamo ora, in una pagina: non il registro delle
|
||||
migration né l'elenco delle funzioni),
|
||||
[docs/decisions/](docs/decisions/README.md) (decisioni architetturali, un file `DD-XXX.md` ciascuna),
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema),
|
||||
[docs/OPERATIONS.md](docs/OPERATIONS.md) (se cambiano variabili d'ambiente o procedure di rilascio).
|
||||
Una decisione nuova è un file `docs/decisions/DD-XXX.md` copiato da `docs/_template-dd.md`, più la
|
||||
riga nell'indice. Per aggiungere una funzione o un modulo segui la checklist in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
|
||||
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
|
||||
|
||||
+35
-210
@@ -1,220 +1,45 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 29/09/2026
|
||||
Ultimo aggiornamento: 06/10/2026
|
||||
|
||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||
schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
||||
[docs/OPERATIONS.md](docs/OPERATIONS.md). Non ripetere qui quello che hanno già loro.
|
||||
|
||||
## Stato generale
|
||||
|
||||
Fase corrente:
|
||||
- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link
|
||||
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
|
||||
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
|
||||
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
|
||||
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
|
||||
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
|
||||
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
|
||||
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
|
||||
26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
|
||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||
è su `main` dalla 1.3.0.
|
||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
||||
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
||||
collega al primo accesso (DD-018).
|
||||
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
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); la prima versione stabile, 1.0.0, ha
|
||||
portato il ruolo allenatore. La versione corrente è la 1.2.0: notifiche su tre canali (push, in-app
|
||||
ed email) e promemoria che non si ripetono (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).
|
||||
## Cose da sapere
|
||||
|
||||
---
|
||||
|
||||
## Infrastruttura
|
||||
|
||||
- Si lavora direttamente su `main` (DD-019): `develop` esiste ma è fermo indietro, quindi la
|
||||
sua preview Vercel non rappresenta lo stato attuale
|
||||
- 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`
|
||||
- 36 migration in `supabase/migrations/`, fino a `m25_promemoria_solo_giocatori` (29/09/2026).
|
||||
Le prime 33 (fino a `m22_notifiche_email`) sono applicate in produzione, verificato con
|
||||
`npx supabase migration list` il 29/09/2026; **`m23`, `m24` e `m25` sono verificate solo in locale
|
||||
e non ancora applicate in produzione**
|
||||
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
|
||||
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
|
||||
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
|
||||
su `develop`, non ancora su `main`
|
||||
- Sviluppo locale verificato con il nuovo Supabase
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
|
||||
- Lovable Cloud: non più backend operativo di CrAPP
|
||||
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
- 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 `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()`
|
||||
include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi
|
||||
(li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con
|
||||
`npx supabase db reset` e i test di integrazione, **non ancora in produzione**
|
||||
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
|
||||
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
|
||||
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
|
||||
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
|
||||
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
|
||||
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
|
||||
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
|
||||
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
|
||||
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
|
||||
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
|
||||
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
|
||||
ancora in produzione**
|
||||
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
|
||||
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
|
||||
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
|
||||
esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e
|
||||
una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal
|
||||
29/09/2026 (`supabase db push`) con il worker in funzione
|
||||
- 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
|
||||
bisogno di una migration
|
||||
- `public.giocatori_squadra` è ora la source of truth della rosa letta dall'app (DD-015,
|
||||
03/09/2026): «Aggiungi giocatore» e «Disattiva giocatore» della dashboard admin si
|
||||
riflettono su Squadra, Presenze, Pagelle, Badge e Scout. `src/lib/crapp-data.ts` resta
|
||||
solo come seed storico, fallback offline e sorgente della data di nascita (colonna non
|
||||
ancora presente su `giocatori_squadra`)
|
||||
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo,
|
||||
al posto di `localStorage` (una per giocatore, letto da `src/lib/avatar-store.ts`)
|
||||
- Migration `m10_azzera_turni_palloni_allenamenti`: toglie i turni palloni salvati sugli
|
||||
allenamenti, che non ricevono più una proposta automatica (vedi
|
||||
[docs/modules/palloni.md](docs/modules/palloni.md))
|
||||
- Migration `m11_scritture_per_ruolo`: le policy di scrittura rispecchiano i permessi
|
||||
dell'interfaccia (DD-023). Fino a M10 un qualsiasi utente autenticato poteva svuotare il
|
||||
calendario o riscrivere il voto di un altro parlando direttamente con PostgREST; la tabella
|
||||
dei permessi sta in [docs/DATABASE.md](docs/DATABASE.md) ed è verificata da
|
||||
`test/integration/permessi.test.ts`
|
||||
- Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
|
||||
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
|
||||
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
|
||||
in `localStorage`, quindi visibili a un solo dispositivo
|
||||
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
|
||||
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
|
||||
pagelle anche `pagelle_chiuse = false`)
|
||||
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
|
||||
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
|
||||
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
|
||||
`scout_sessioni`, `scout_live`, `scout_partite`)
|
||||
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
|
||||
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
|
||||
social legati a id Scout o CSI
|
||||
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
|
||||
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
|
||||
role), se mai servisse di nuovo
|
||||
|
||||
---
|
||||
|
||||
## Moduli completati
|
||||
|
||||
- Squadra
|
||||
- Presenze
|
||||
- Badge (incluso badge social)
|
||||
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
||||
- Pagelle
|
||||
- MVP
|
||||
- Obiettivi di squadra
|
||||
- Turno palloni (specifica in `docs/modules/palloni.md`)
|
||||
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
|
||||
- Notifiche
|
||||
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
||||
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
|
||||
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
|
||||
|
||||
---
|
||||
|
||||
## Autenticazione e dashboard amministratore
|
||||
|
||||
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
|
||||
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
|
||||
permessi di amministrazione arrivano solo da `user_roles`.
|
||||
|
||||
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
|
||||
Google» risponde
|
||||
|
||||
```
|
||||
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
|
||||
```
|
||||
|
||||
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
|
||||
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
|
||||
|
||||
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
|
||||
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
|
||||
attivi hanno già fatto il primo accesso).**
|
||||
|
||||
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
|
||||
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
|
||||
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
|
||||
_Web application_ con redirect URI
|
||||
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
|
||||
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
|
||||
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
|
||||
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
|
||||
`[auth.external.google]` di `supabase/config.toml`, le due variabili
|
||||
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
|
||||
`http://127.0.0.1:54321/auth/v1/callback`.
|
||||
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
|
||||
produzione senza toccare il comportamento attuale.
|
||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
|
||||
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
|
||||
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
|
||||
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
|
||||
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
|
||||
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
|
||||
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
|
||||
slot già collegato può essere liberato solo da un admin.
|
||||
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
|
||||
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
|
||||
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
|
||||
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
|
||||
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
|
||||
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
|
||||
di accesso diretto al database bypassando l'app.
|
||||
|
||||
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
|
||||
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0) e le notifiche su tre
|
||||
canali (DD-036–DD-039, 1.2.0) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
|
||||
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
||||
slot lo occupa anche in produzione e va liberato da un admin.
|
||||
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
|
||||
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
|
||||
|
||||
## Note
|
||||
|
||||
Il progetto segue una metodologia document-first.
|
||||
|
||||
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
|
||||
Il progetto segue una metodologia document-first (DD-002): ogni funzionalità si progetta in
|
||||
`docs/modules/` prima di essere implementata.
|
||||
@@ -49,12 +49,9 @@ committare lo decide chi sviluppa (DD-019).
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
Il progetto richiede le seguenti variabili:
|
||||
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_PUBLISHABLE_KEY`
|
||||
- `VITE_SUPABASE_URL`
|
||||
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||
Per far partire l'app servono `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `VITE_SUPABASE_URL` e
|
||||
`VITE_SUPABASE_PUBLISHABLE_KEY`. L'elenco completo (service role, VAPID, SMTP del worker) sta in
|
||||
[docs/OPERATIONS.md](docs/OPERATIONS.md).
|
||||
|
||||
## Documentazione
|
||||
|
||||
|
||||
+42
-1
@@ -85,9 +85,13 @@ quando il database non risponde.
|
||||
## UI
|
||||
|
||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
|
||||
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
schermate, pochi click.
|
||||
|
||||
**Caratteri** (DD-049): niente testo sotto i 13 px, tranne la navigazione a pillola e i badge
|
||||
da 10-11 px. Scala in uso: 13 px note, 15 px corpo, 16 px titoli di card; si usano le misure
|
||||
fisse `text-[13px]`…`text-[16px]`, non `text-xs` e `text-sm`.
|
||||
|
||||
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
|
||||
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
|
||||
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
|
||||
@@ -102,6 +106,43 @@ Il movimento usa molle interrompibili di `motion` con i preset in `src/lib/molla
|
||||
lanciato, così swipe come quello del calendario atterrano dove il gesto stava andando.
|
||||
`src/lib/motion.ts` conserva solo il rilevamento del movimento ridotto e i coriandoli.
|
||||
|
||||
## Trappole note
|
||||
|
||||
Cose che si scoprono solo rompendole; ognuna ha la sua decisione o il suo documento.
|
||||
|
||||
- **Non invalidare la cache**: dopo una mutazione si usa `setQueryData`, non `invalidateQueries`
|
||||
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
|
||||
- **Niente database dai componenti**: solo tramite `src/lib/` (DD-013).
|
||||
- **Tabelle presenti ma non usate**: `giocatori`, `eventi`, `presenze` (DD-014) e `promemoria_push`.
|
||||
Il modello in uso è `giocatori_squadra`, `eventi_app`, `risposte_presenze`.
|
||||
- **Route in `src/routes/api/public/`**: usano la service role e saltano la RLS, quindi chiedono
|
||||
le credenziali (DD-024). Una nuova route che avvisa la squadra deve farlo.
|
||||
- **`convocati` vuoto significa tutta la rosa**, non nessuno (DD-027).
|
||||
- **L'allenatore non è un giocatore**: è fuori da `inRosa()`, non vota e non riceve i promemoria
|
||||
(DD-034, DD-039).
|
||||
- **Cancellare un evento** pulisce a cascata le tabelle collegate; una tabella nuova legata
|
||||
agli eventi va aggiunta al trigger (DD-029).
|
||||
- **Dev e produzione condividono lo stesso Supabase**: le prove distruttive si fanno sullo stack
|
||||
locale ([OPERATIONS.md](OPERATIONS.md)). Il seed gira solo in locale.
|
||||
- **Migration**: mai riscrivere una già applicata; un `CHECK` sul tipo di `notifiche_utente` va
|
||||
esteso con una migration nuova quando nasce un tipo nuovo.
|
||||
- **Dipendenze**: `bunfig.toml` impone `minimumReleaseAge = 24h`; escluderne un pacchetto richiede
|
||||
conferma dell'utente.
|
||||
|
||||
## Aggiungere un modulo o una funzione
|
||||
|
||||
Nell'ordine, perché il progetto è document-first (DD-002):
|
||||
|
||||
1. scheda in `docs/modules/<nome>.md` (stato, file principali, obiettivo, dati, permessi) e riga in
|
||||
[ROADMAP.md](ROADMAP.md); se la scelta è non ovvia, una `DD-XXX` in [decisions/](decisions/README.md);
|
||||
2. se serve lo schema: tabella documentata in [DATABASE.md](DATABASE.md), **nuova** migration,
|
||||
permessi di scrittura nella sua tabella e test in `test/integration/permessi.test.ts`;
|
||||
3. logica pura in `src/lib/<nome>-core.ts` con test in `test/unit/`; hook e accesso ai dati in
|
||||
`src/lib/<nome>.ts`; componenti in `src/components/crapp/`, riusando quelli esistenti;
|
||||
4. se tocca le notifiche: il catalogo in [modules/notifiche.md](modules/notifiche.md);
|
||||
5. voce in [CHANGELOG.md](CHANGELOG.md) sotto `[Non rilasciato]` e checklist «Fine lavoro» di
|
||||
[AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
|
||||
@@ -13,6 +13,207 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.4.2] - 2026-10-26
|
||||
|
||||
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
|
||||
26/10/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
|
||||
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
|
||||
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
|
||||
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
|
||||
|
||||
## [1.4.1] - 2026-10-19
|
||||
|
||||
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
|
||||
Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
|
||||
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
|
||||
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
|
||||
|
||||
### Corretto
|
||||
|
||||
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
|
||||
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
|
||||
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
|
||||
vuoto resta «tutta la rosa».
|
||||
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
|
||||
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
|
||||
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
|
||||
eventi a cui non sei convocato.
|
||||
|
||||
## [1.4.0] - 2026-10-09
|
||||
|
||||
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
|
||||
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
|
||||
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
|
||||
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
|
||||
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
|
||||
|
||||
## [1.3.1] - 2026-10-06
|
||||
|
||||
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
|
||||
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
|
||||
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
|
||||
(«Nessun compleanno questo mese»).
|
||||
|
||||
### Rimosso
|
||||
|
||||
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
|
||||
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
|
||||
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
|
||||
|
||||
## [1.3.0] - 2026-10-06
|
||||
|
||||
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
|
||||
e le presenze si contano solo sugli eventi a cui si è convocati. In tutta l'app i caratteri sono più grandi e gli
|
||||
avvisi in Home più chiari. La migration `m31_notifica_compleanno` è **da applicare in produzione** (`npx supabase
|
||||
db push`); il resto non tocca il database.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri
|
||||
attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo
|
||||
messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration
|
||||
`m31_notifica_compleanno`, da applicare in produzione.
|
||||
- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che
|
||||
spiega tutto: come si calcola, cosa conta e cosa no, cosa è una presenza persa, periodo di validità, fonte dei dati,
|
||||
un esempio con numeri. Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se
|
||||
non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Assente, forse,
|
||||
infortunato e nessuna risposta restano presenze perse. Nessuna migration.
|
||||
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
|
||||
rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. Nessuna migration.
|
||||
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
|
||||
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
|
||||
- **Avvisi in Home più leggibili** — certificati e turno palloni usano lo stesso componente (`Avviso`): icona
|
||||
propria per ogni caso (non solo il colore), titolo e testo più grandi, elenchi di persone in righe con il dettaglio
|
||||
a destra e il conteggio, freccia dove la card porta ai documenti, comparsa graduale. Il turno palloni ha la pillola
|
||||
«Oggi»/«In arrivo» e i messaggi in riquadri. Solo aspetto: testi e regole di quando compaiono non cambiano.
|
||||
Nessuna migration.
|
||||
- **Caratteri più grandi in tutta l'app** — ogni testo sotto i 16 px sale di un passo (12→13, 14→15, ecc.), nelle
|
||||
card, negli elenchi, nelle statistiche e nei drawer. Restano uguali le barre a pillola dei menu (navigazione in
|
||||
basso e sottosezioni) e i badge minuscoli. Solo aspetto, nessuna migration (DD-049).
|
||||
- **Etichetta «Cacche» nella rosa** — nella scheda del giocatore in Squadra la riga «Cacche/partita» si chiama
|
||||
«Cacche»; il valore resta la media per partita. Nessuna migration.
|
||||
|
||||
### Rimosso
|
||||
|
||||
- **Tessera CSI dalla dashboard** — in Profili spariscono il blocco «Tesseramento CSI» (numero e data
|
||||
tessera), il badge «Tesserato»/«Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non
|
||||
cambia. Le colonne `numero_tessera` e `data_tessera` restano a database, non più usate (DD-048). Nessuna migration.
|
||||
|
||||
## [1.2.3] - 2026-10-04
|
||||
|
||||
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
|
||||
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
|
||||
|
||||
### Sicurezza
|
||||
|
||||
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
|
||||
|
||||
## [1.2.2] - 2026-10-01
|
||||
|
||||
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
|
||||
promemoria degli eventi non c'è più, la foto tessera è stata tolta e l'avviso del certificato medico arriva al
|
||||
giocatore un mese prima. Le migration `m27`–`m30` sono applicate in produzione dal 01/10/2026; questa versione porta il
|
||||
codice dell'app che le accompagna, da rilasciare subito: la `m29` ha tolto la colonna `foto_path` che la versione
|
||||
precedente ancora legge.
|
||||
|
||||
### Rimosso
|
||||
|
||||
- **Foto tessera del profilo** — il giocatore non la carica più e l'admin non la vede nella scheda; il
|
||||
completamento del profilo si calcola su dati personali (34%), documento (33%) e certificato (33%). Migration
|
||||
`m29_senza_foto_tessera` (elimina la colonna `foto_path`); i file già caricati sono stati cancellati dal
|
||||
bucket `profili-giocatore` (DD-044). Applicata in produzione il 01/10/2026, con la cancellazione dei 4 file già caricati.
|
||||
|
||||
- **Promemoria evento a 24 e 3 ore** — non partono più per nessun evento (allenamenti, partite, extra-campo):
|
||||
niente notifica, push né email. Migration `m28_niente_promemoria_evento` (tolti i due job `pg_cron`;
|
||||
funzione, tipi e notifiche già generate restano). Applicata in produzione il 01/10/2026 (DD-043).
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Sollecito presenze: un solo avviso automatico, 24 ore prima** — a chi non ha risposto o ha risposto
|
||||
«forse»; prima ne partivano tre (24, 12 e 6 ore). Il pulsante «Sollecita» non cambia. Migration
|
||||
`m30_sollecito_solo_24h`, applicata in produzione il 01/10/2026 (DD-045).
|
||||
|
||||
- **Turno palloni: un solo avviso, 3 ore prima** — a chi deve portare i palloni e a chi li deve
|
||||
prendere; prima ne partivano tre (12, 6 e 3 ore). Migration `m27_palloni_solo_3h`, applicata in
|
||||
produzione il 01/10/2026 (DD-042).
|
||||
|
||||
- **Avviso certificati: giocatore a 30 giorni** — il giocatore vede l'avviso giallo sul proprio
|
||||
certificato medico da un mese prima della scadenza (prima 7 giorni). Per gli admin non cambia
|
||||
nulla: 7 giorni prima in giallo, nero dopo la scadenza (DD-041).
|
||||
|
||||
## [1.2.1] - 2026-09-30
|
||||
|
||||
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
|
||||
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
|
||||
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
|
||||
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
|
||||
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
|
||||
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
|
||||
turno» resta.
|
||||
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
|
||||
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
|
||||
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
|
||||
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
|
||||
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
|
||||
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
|
||||
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
|
||||
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
|
||||
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
|
||||
non riceve tutte le notifiche insieme.
|
||||
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
|
||||
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
|
||||
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
|
||||
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
|
||||
- **Documentazione** — DD-040 in `decisions/`; sezione «Testi delle notifiche» nel catalogo di
|
||||
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
|
||||
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
|
||||
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
|
||||
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
|
||||
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
|
||||
palloni manuale non parte più per email.
|
||||
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
|
||||
di riconsegna, come già descritto nel catalogo.
|
||||
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
|
||||
«successivo» coincidono con quelli dei job del database.
|
||||
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
|
||||
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
|
||||
|
||||
### Corretto
|
||||
|
||||
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
|
||||
|
||||
## [1.2.0] - 2026-09-29
|
||||
|
||||
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
|
||||
|
||||
+115
-43
@@ -9,8 +9,8 @@ non in questo file.
|
||||
|
||||
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).
|
||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
|
||||
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
|
||||
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@@ -22,6 +22,7 @@ nessuna di esse da M4 (DD-011).
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `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) |
|
||||
@@ -30,67 +31,138 @@ nessuna di esse da M4 (DD-011).
|
||||
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.
|
||||
|
||||
## Lettura
|
||||
|
||||
Regola generale: ogni autenticato legge. Le eccezioni sono tutte dati personali o code interne:
|
||||
|
||||
| Tabella / bucket | Chi legge |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `profili_giocatore` | il titolare e gli admin (contiene documento d'identità e certificato medico, DD-016) |
|
||||
| `bucket profili-giocatore` | privato: il titolare la propria cartella, l'admin tutto via signed URL a scadenza breve |
|
||||
| `notifiche_utente` | il destinatario, solo le proprie righe (RLS, DD-030) |
|
||||
| `preferenze_utente` | l'utente, solo la propria riga |
|
||||
| `notifiche_email_coda`, `notifiche_push_coda`, `promemoria_eventi_generati` | nessuno da client: RLS attiva senza policy, solo service role |
|
||||
| `giocatori_squadra.nascita` | tutta la rosa attiva: è una colonna pubblica sincronizzata, non una lettura di `profili_giocatore` (DD-031) |
|
||||
|
||||
## 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. 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). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra: source of truth della rosa (DD-015). | ID testuali (`g1`…`gN`). Dettaglio colonne qui sotto. Letta con `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`; `src/lib/crapp-data.ts` resta solo come seed e fallback (`rosaFallback()`). |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento, certificato medico e path dei file; 1:1 con `giocatori_squadra`. | Letta da `src/lib/profili.ts` (DD-016). I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (`admin`, `allenatore`, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano (vedi [OPERATIONS.md](OPERATIONS.md)). `allenatore` non si inserisce a mano: lo gestisce il trigger `sincronizza_ruolo_allenatore` (DD-034). |
|
||||
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
Colonne di `giocatori_squadra` che meritano una nota:
|
||||
|
||||
| Colonna | Significato |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
|
||||
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
|
||||
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
|
||||
| `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. |
|
||||
|
||||
`giocatori_squadra` è usata da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
## Storage
|
||||
|
||||
| Bucket | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m2_profili_giocatore`. |
|
||||
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
|
||||
| Bucket | Scopo | Note |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `profili-giocatore` | Documento d'identità e certificato medico, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato e destinato a restare tale**: documenti e dati sanitari non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella; l'admin scarica tutto con signed URL a scadenza breve. |
|
||||
| `avatar-giocatori` | Foto profilo (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali. Qualsiasi autenticato può caricare, sostituire o eliminare un file (nessun controllo per proprietario: la maggior parte dei giocatori non ha ancora `auth_user_id`, DD-018). Letto da `src/lib/avatar-store.ts`. |
|
||||
|
||||
## 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 usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. |
|
||||
|
||||
## Votazioni
|
||||
|
||||
| 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`. |
|
||||
| `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`). |
|
||||
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
|
||||
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. |
|
||||
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. |
|
||||
|
||||
## 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. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): 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`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre 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. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); 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. |
|
||||
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
|
||||
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
|
||||
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. |
|
||||
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. |
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `turni_palloni` | Turni palloni **confermati**. | Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)). |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche push. | |
|
||||
| `promemoria_push` | **Non più usata.** | Serviva da coda del testo quando la push partiva vuota (DD-026). Da eliminare con una migration. |
|
||||
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto. | Vedi sotto. Letta e segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), solo via RLS. |
|
||||
| `notifiche_email_coda` | Coda delle email: una riga per notifica, con stato d'invio. | Vedi sotto. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
|
||||
| `notifiche_push_coda` | Coda delle push dei promemoria: una riga per notifica, con stato. | Vedi sotto. |
|
||||
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Chiave `auth_user_id`. Oggi una colonna, `email_notifiche` (default `true`). **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore in Profilo → Opzioni (`useEmailNotifiche()`). |
|
||||
| `promemoria_eventi_generati` | Registro dei promemoria già generati, per giocatore, evento e tipo. | Chiave `(giocatore_id, evento_id, tipo)`, `ON DELETE CASCADE` verso giocatore ed evento (DD-029, DD-037). Eliminare la notifica non fa rigenerare il promemoria. È anche la memoria che permette la revoca (DD-040). |
|
||||
|
||||
## Funzioni speciali
|
||||
### `notifiche_utente`
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ---------------- | --------------------- | -------------------------------------- |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
- Sorgenti: messaggio libero dell'admin, promemoria evento automatici, turno palloni, sollecito
|
||||
presenze e sondaggio pre-partita (`sondaggio_cacche`). Dettaglio e testi in
|
||||
[modules/notifiche.md](modules/notifiche.md).
|
||||
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
||||
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||
- Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
||||
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
||||
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
||||
automatici (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
|
||||
|
||||
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
||||
|
||||
Stessa struttura: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`; stati
|
||||
`in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo`
|
||||
(backoff), `errore` (solo codici SMTP per l'email, HTTP per la push), `inviata_il`, `aggiornata_il`.
|
||||
|
||||
- Sono riempite da trigger su `notifiche_utente` (`accoda_notifica_email`, `accoda_push_promemoria`)
|
||||
per le notifiche create dopo le rispettive migration, senza backfill.
|
||||
- Le usa solo il worker `mailer/` con la service role, tramite funzioni `SECURITY DEFINER`
|
||||
riservate a `service_role`: `prendi_notifiche_email(p_max)`, `esito_notifica_email(...)`,
|
||||
`email_inviate_ultime_24h()`, `prendi_push_promemoria(p_max)`, `esito_push_promemoria(...)`.
|
||||
I lotti usano `FOR UPDATE SKIP LOCKED` e recuperano le righe ferme in `in_invio` da più di 10 minuti.
|
||||
- RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`.
|
||||
- Sono tabelle separate da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga
|
||||
gli permetterebbe di riportare a `in_coda` una mail già partita (DD-036).
|
||||
- Il worker salta gli slot senza account o email, o con le email spente; per le push salta chi non
|
||||
ha dispositivi e i promemoria creati da oltre 3 ore.
|
||||
|
||||
### Funzioni SQL e job delle notifiche
|
||||
|
||||
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
|
||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
||||
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Badge
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,7 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
|
||||
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
|
||||
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
|
||||
letto.
|
||||
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
|
||||
6. **Push solo per eventi importanti**: convocazioni, turno
|
||||
palloni, esito finale.
|
||||
7. **Niente funzionalità pesanti**: foto, video, chat.
|
||||
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Glossario
|
||||
|
||||
Termini del dominio e del progetto, per chi (persona o assistente AI) non conosce la squadra o
|
||||
il campionato. Dove serve il dettaglio c'è il rimando al modulo.
|
||||
|
||||
| Termine | Significato |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Slot** | Riga di `giocatori_squadra`: la «casella» di una persona della squadra. L'account Google la reclama al primo accesso (`auth_user_id`), collegandosi per email (DD-018). Un admin può liberarla. |
|
||||
| **Rosa** | I giocatori attivi di gioco (`inRosa()`): esclude gli slot disattivati e l'allenatore (DD-034). La legge `useRosa()`. |
|
||||
| **Allenatore** | Slot con `tipo = 'allenatore'`: gestisce gli eventi, ma non gioca, non ha numero né riceve i promemoria degli eventi (DD-034, DD-039). Vedi [modules/allenatore.md](modules/allenatore.md). |
|
||||
| **Admin** | Chi ha il ruolo `admin` in `user_roles`. Gestisce squadra, profili e notifiche; i permessi veri stanno nelle policy RLS ([DATABASE.md](DATABASE.md)). |
|
||||
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
|
||||
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
|
||||
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
|
||||
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
|
||||
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
|
||||
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
|
||||
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
|
||||
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
|
||||
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
|
||||
| **Scout / Scout Live** | Rilevazione in tempo reale delle azioni di una partita, con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
|
||||
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
|
||||
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
|
||||
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
|
||||
| **Tre canali** | Ogni notifica può uscire come in-app, push ed email (DD-038). Email e push dei promemoria partono dal **worker** (`mailer/`). |
|
||||
| **Worker** | Il container Docker in `mailer/` che invia email e push dei promemoria con la service role ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
|
||||
| **Service role** | Chiave Supabase che bypassa la RLS. Solo server e worker, mai il client ([OPERATIONS.md](OPERATIONS.md)). |
|
||||
| **DD-XXX** | Decisione registrata in [decisions/](decisions/README.md). |
|
||||
| **M1…M27** | Numero d'ordine delle migration (`m27_palloni_solo_3h`…) in `supabase/migrations/`. |
|
||||
@@ -0,0 +1,106 @@
|
||||
# Operatività: ambienti, variabili e procedure
|
||||
|
||||
Come si allestisce, si configura e si rilascia CrAPP. Le scelte di fondo sono in
|
||||
[PORTABILITA.md](PORTABILITA.md) (cosa lega l'app a un fornitore, come spostarla) e in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md); il worker delle email ha il suo manuale in
|
||||
[WORKER_EMAIL.md](WORKER_EMAIL.md).
|
||||
|
||||
## Ambienti
|
||||
|
||||
| Ambiente | Dove | Note |
|
||||
| ---------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Produzione | Vercel (`main`) + Supabase proprietario | Project Ref `kfkcldwncxqaixetsjes`. Il vecchio `hetycilxgkdmccelwerq` è deprecato: non usarlo. |
|
||||
| Preview | Vercel, da `develop` e dai branch | `develop` è fermo indietro rispetto a `main` (DD-019): non rappresenta lo stato attuale. |
|
||||
| Locale | `npm run dev` (8080) + `npx supabase start` | Database in Docker con migration e seed. Dev e produzione possono condividere lo stesso progetto Supabase: vedi «Attenzione». |
|
||||
| Worker | Raspberry Pi con Docker (`mailer/`) | Email e push dei promemoria. Vedi [WORKER_EMAIL.md](WORKER_EMAIL.md). |
|
||||
|
||||
Lovable Cloud non è più il backend di CrAPP (DD-001).
|
||||
|
||||
## Variabili d'ambiente
|
||||
|
||||
Nomi usati dal codice; i valori stanno in `.env` / `.env.local` (mai nel repository) e nelle
|
||||
Environment Variables di Vercel (Preview e Production).
|
||||
|
||||
| Variabile | Dove serve | Per cosa |
|
||||
| ----------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------- |
|
||||
| `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_KEY` | client (browser) | Connessione pubblica a Supabase |
|
||||
| `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` | server | Come sopra, lato route |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | server e worker | Bypassa la RLS: solo nelle route `src/routes/api/public/` e nel worker. Mai al client. |
|
||||
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | server e worker | Firma delle notifiche push. Le chiavi devono essere le stesse nell'app e nel worker. |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS` | worker | Invio email via Gmail (password per app) |
|
||||
| `MAILER_FILE_SALUTE` | worker | File usato dall'healthcheck del container |
|
||||
| `SUPABASE_AUTH_GOOGLE_CLIENT_ID`, `SUPABASE_AUTH_GOOGLE_SECRET` | Supabase locale | Solo per provare il login Google sullo stack locale |
|
||||
|
||||
Se aggiungi una variabile, aggiungila anche qui.
|
||||
|
||||
## Migration
|
||||
|
||||
Ordine obbligatorio (vedi anche [AGENTS.md](../AGENTS.md), sezione Database):
|
||||
|
||||
1. si progetta e si documenta in [DATABASE.md](DATABASE.md);
|
||||
2. si crea una **nuova** migration in `supabase/migrations/` (mai riscrivere quelle applicate);
|
||||
3. si prova in locale con `npx supabase db reset` e `npm run test:all`;
|
||||
4. solo dopo `npx supabase db push` verso il cloud;
|
||||
5. si verifica con `npx supabase migration list` che locale e remoto coincidano.
|
||||
|
||||
## Rilascio
|
||||
|
||||
`main` è la produzione: ogni commit deve lasciare l'app funzionante. Il deploy è quello di Vercel
|
||||
sul push di `main`; un cron giornaliero (`/api/public/keepalive`, `vercel.json`) evita la pausa
|
||||
del progetto. Prima di dire «finito» vale la checklist «Fine lavoro» di [AGENTS.md](../AGENTS.md).
|
||||
Per tornare indietro: promuovere il deployment precedente da Vercel; le migration, essendo
|
||||
additive, non si annullano ma si correggono con una migration nuova.
|
||||
|
||||
## Allestire un ambiente: autenticazione e primo admin
|
||||
|
||||
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
|
||||
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
|
||||
permessi di amministrazione arrivano solo da `user_roles`.
|
||||
|
||||
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
|
||||
Google» risponde
|
||||
|
||||
```
|
||||
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
|
||||
```
|
||||
|
||||
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
|
||||
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
|
||||
|
||||
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
|
||||
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
|
||||
attivi hanno già fatto il primo accesso).**
|
||||
|
||||
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
|
||||
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
|
||||
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
|
||||
_Web application_ con redirect URI
|
||||
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
|
||||
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
|
||||
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
|
||||
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
|
||||
`[auth.external.google]` di `supabase/config.toml`, le due variabili
|
||||
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
|
||||
`http://127.0.0.1:54321/auth/v1/callback`.
|
||||
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
|
||||
produzione senza toccare il comportamento attuale.
|
||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
|
||||
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
|
||||
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
|
||||
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
|
||||
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
|
||||
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
|
||||
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
|
||||
slot già collegato può essere liberato solo da un admin.
|
||||
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
|
||||
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
|
||||
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
|
||||
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
|
||||
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
|
||||
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
|
||||
di accesso diretto al database bypassando l'app.
|
||||
|
||||
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
|
||||
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
+21
-16
@@ -2,29 +2,34 @@
|
||||
|
||||
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
|
||||
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
|
||||
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
|
||||
ancora e va **prima documentata** (vedi [DD-002](decisions/DD-002.md)).
|
||||
|
||||
## 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 (backup/disaster recovery incluso) |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
|
||||
| [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 |
|
||||
| [decisions/](decisions/README.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 |
|
||||
| [GLOSSARIO.md](GLOSSARIO.md) | Cosa significano i termini del dominio (slot, rosa, convocati, CSI, pagelle…) |
|
||||
| [OPERATIONS.md](OPERATIONS.md) | Ambienti, variabili d'ambiente, migration, rilascio e allestimento di un ambiente nuovo (login Google, primo admin) |
|
||||
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [archive/](archive/README.md) | Materiale storico non più in uso: non è fonte di verità |
|
||||
| [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).
|
||||
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md) (una pagina: a che punto
|
||||
siamo, non la cronologia).
|
||||
|
||||
## Ordine di lettura
|
||||
|
||||
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
|
||||
→ `DATABASE.md` → `DESIGN_DECISIONS.md` → il documento del modulo interessato in `modules/`.
|
||||
→ `DATABASE.md` → il documento del modulo interessato in `modules/` → solo le decisioni
|
||||
(`decisions/DD-XXX.md`) che il modulo o l'indice richiamano, non tutte.
|
||||
|
||||
## Regole di manutenzione
|
||||
|
||||
@@ -37,8 +42,8 @@ Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
|
||||
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
|
||||
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
|
||||
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
|
||||
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
|
||||
una si copia [\_template-dd.md](_template-dd.md).
|
||||
- le motivazioni stanno solo in `decisions/`, un file `DD-XXX.md` per decisione; per
|
||||
aggiungerne una si copia [\_template-dd.md](_template-dd.md) e si aggiorna l'indice.
|
||||
|
||||
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
|
||||
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
|
||||
+13
-6
@@ -27,10 +27,10 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
|
||||
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
|
||||
resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
|
||||
di tessera è stato tolto dalla dashboard (DD-048)
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
@@ -38,12 +38,19 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
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)
|
||||
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la
|
||||
scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046)
|
||||
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
|
||||
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
||||
([specifica](modules/notifiche-email.md), DD-036)
|
||||
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
|
||||
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||
|
||||
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
|
||||
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
|
||||
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
|
||||
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
|
||||
|
||||
## Prossimo
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Worker email: installazione e gestione
|
||||
|
||||
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
|
||||
per email e la push dei promemoria a 24 e 3 ore. Cosa fa il canale email sta in
|
||||
per email e le push delle notifiche che nascono nel database: turno palloni a 3 ore con revoca, sollecito presenze a 24 ore (DD-040). Cosa fa il canale email sta in
|
||||
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
|
||||
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
|
||||
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
|
||||
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
|
||||
[DD-036](decisions/DD-036.md)
|
||||
e [DD-038](decisions/DD-038.md).
|
||||
Il nome del documento resta quello delle email, che sono il canale principale.
|
||||
|
||||
**Stato in produzione:** migration `m22` applicata e worker delle email in funzione dal 29/09/2026,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Archivio
|
||||
|
||||
Materiale **storico, non più in uso**: non è fonte di verità e non va letto per capire lo stato
|
||||
attuale. Lo schema vigente sono le migration in `supabase/migrations/`.
|
||||
|
||||
| Cartella | Contenuto |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `migrations/` | Le due prime migration del Profilo Giocatore (m2, m3), sostituite da `m2_profili_giocatore` e da `correzione_schema_profili_giocatore` in `supabase/migrations/`. Conservate solo come riferimento. |
|
||||
@@ -0,0 +1,24 @@
|
||||
# DD-001 — CrAPP deve restare indipendente da Lovable
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il progetto nasce come prototipo su Lovable Cloud. Per crescere serve controllo su codice, deploy, database e costi.
|
||||
|
||||
**Decisione**
|
||||
Spostare lo sviluppo su repository GitHub indipendente, con deploy su Vercel e database Supabase gestito dal team.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Restare su Lovable come unica piattaforma → troppa dipendenza da un servizio esterno.
|
||||
- Riscrivere tutto da zero → costo e rischio inutili; il prototipo funzionava già.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Maggiore libertà e responsabilità per il team.
|
||||
- Restano tracce del passaggio (dipendenze, meta tag): vanno eliminate gradualmente, non in blocco.
|
||||
- L’app deve poter girare anche fuori dall’ecosistema Lovable (vedi `PORTABILITA.md`).
|
||||
|
||||
**Riesame**
|
||||
Quando il progetto non userà più alcun componente Lovable.
|
||||
@@ -0,0 +1,24 @@
|
||||
# DD-002 — Sviluppo document-first
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Con più persone (e assistenti AI) che lavorano sul codice, serviva un modo per evitare funzionalità “inventate” al volo e incoerenze tra moduli.
|
||||
|
||||
**Decisione**
|
||||
Ogni nuova funzionalità significativa viene prima **progettata e documentata** in `docs/modules/`, poi implementata. Il flusso ufficiale è: idea → progettazione → documentazione → database → codice → test → release.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Documentare solo a posteriori → troppo spesso incompleto o assente.
|
||||
- Affidarsi solo al codice come documentazione → illeggibile per chi non programma.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
|
||||
- I moduli preesistenti vanno retro-documentati quando possibile.
|
||||
- Nessuna feature non documentata entra in produzione.
|
||||
|
||||
**Riesame**
|
||||
Se il team diventa molto piccolo e la documentazione smette di essere consultata.
|
||||
@@ -0,0 +1,26 @@
|
||||
# DD-003 — Due branch: main stabile, develop per il lavoro
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Sostituita da [DD-019](DD-019.md) (settembre 2026)
|
||||
|
||||
**Contesto**
|
||||
Serve separare ciò che i giocatori usano ogni giorno da ciò che è ancora in prova.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- `main` → produzione, sempre funzionante, deploy automatico.
|
||||
- `develop` → sviluppo e preview, merge su `main` solo dopo test.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Sviluppare direttamente su `main` → rischio di rotture in produzione.
|
||||
- Branch per ogni feature → eccessivo per la dimensione attuale del team.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Gli utenti in produzione non vedono lavori incompleti.
|
||||
- Ogni release su `main` deve includere verifica delle funzionalità esistenti.
|
||||
|
||||
**Riesame**
|
||||
Sostituita: nella pratica il lavoro è finito direttamente su `main` e `develop` è rimasto
|
||||
indietro. Vedi DD-019.
|
||||
@@ -0,0 +1,22 @@
|
||||
# DD-004 — Ogni versione aggiunge, non riscrive
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
CrAPP è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
|
||||
|
||||
**Decisione**
|
||||
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Refactoring ampio “per pulire” insieme a ogni release → alto rischio, poco valore immediato per gli utenti.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Coesistono temporaneamente soluzioni vecchie e nuove (es. dati hardcoded accanto a tabelle database).
|
||||
- Il debito tecnico va gestito con migration dedicate, non di nascosto.
|
||||
|
||||
**Riesame**
|
||||
Quando un modulo diventa ingestibile o blocca una release importante.
|
||||
@@ -0,0 +1,23 @@
|
||||
# DD-005 — Mobile-first, pochi click, pochi schermi
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I giocatori usano l’app soprattutto da smartphone, spesso in spogliatoio o in palestra, con poco tempo e poca pazienza.
|
||||
|
||||
**Decisione**
|
||||
Interfaccia semplice, veloce, ottimizzata per telefono. Navigazione ridotta (barra inferiore). Ogni schermata deve avere uno scopo chiaro.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Layout da desktop con menu complessi → scomodo in mobilità.
|
||||
- App nativa iOS/Android → costi e tempi di pubblicazione non giustificati per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Funzionalità amministrative complesse vanno semplificate o suddivise con cura.
|
||||
- La PWA è la forma giusta per questo pubblico.
|
||||
|
||||
**Riesame**
|
||||
Se emergono esigenze desktop forti (es. gestione documenti massiva solo da PC).
|
||||
@@ -0,0 +1,22 @@
|
||||
# DD-006 — Intelligenza artificiale solo se porta beneficio reale
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
L’AI è attraente ma può complicare l’app, aumentare i costi e creare aspettative irrealistiche.
|
||||
|
||||
**Decisione**
|
||||
Usare l’AI solo quando riduce lavoro agli admin o migliora concretamente l’esperienza dei giocatori. Non introdurla “perché si può”.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- AI ovunque (chatbot, suggerimenti automatici, analisi predittive) → fuori focus per una squadra amatoriale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- “AI Allenamenti” è tra le voci ancora da fare in roadmap.
|
||||
- Ogni proposta AI va valutata con la domanda: _chi risparmia tempo e quanto?_
|
||||
|
||||
**Riesame**
|
||||
Quando l’AI diventa economica e affidabile per casi d’uso chiari (es. generazione allenamenti).
|
||||
@@ -0,0 +1,23 @@
|
||||
# DD-007 — Badge calcolati dall’app, non salvati nel database
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I badge dipendono da statistiche già disponibili (presenze, MVP, cacche, ecc.). Salvare ogni badge sbloccato nel database aggiungerebbe complessità senza beneficio immediato.
|
||||
|
||||
**Decisione**
|
||||
I badge vengono **calcolati al volo** dall’applicazione in base ai dati esistenti. Non esiste una tabella badge dedicata.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Tabella `badge_sbloccati` con storico → utile in futuro per notifiche retroattive o audit, ma non necessaria ora.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Meno migration e meno sincronizzazione.
|
||||
- Lo “sblocco” celebrativo usa cache locale per non ripetere animazioni.
|
||||
- Un eventuale storico badge richiederà una nuova decisione.
|
||||
|
||||
**Riesame**
|
||||
Se servono badge manuali assegnati dagli admin o storico immutabile.
|
||||
@@ -0,0 +1,22 @@
|
||||
# DD-008 — Gamification equa tra ruoli
|
||||
|
||||
**Data:** origine progetto
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
In pallavolo i ruoli hanno statistiche diverse (un libero non segna punti d’attacco). Confrontare tutti sugli stessi numeri sarebbe ingiusto e scoraggiante.
|
||||
|
||||
**Decisione**
|
||||
Le statistiche **personali** in profilo e squadra devono essere **eque per tutti i ruoli**. Dati tecnici di reparto (punti, ace, muri) restano nello Scout Live come informazione di squadra, non come leva competitiva individuale.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Classifiche individuali basate su punti → penalizza libero, palleggiatore, centrale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
|
||||
- Lo scout resta strumento tecnico, non gioco.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
||||
@@ -0,0 +1,24 @@
|
||||
# DD-009 — Tesseramento CSI manuale, integrazione API in una fase successiva
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il modulo Profilo Giocatore deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- **Prima fase:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
|
||||
- **Fase successiva:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Integrazione CSI già nella prima fase → scope troppo ampio, dipendenza da API esterne non controllate.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
|
||||
- L’export CSV deve essere affidabile e completo: è il deliverable chiave di questa prima fase.
|
||||
|
||||
**Riesame**
|
||||
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
|
||||
@@ -0,0 +1,23 @@
|
||||
# DD-010 — Profilo giocatore: niente storico certificati
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
|
||||
|
||||
**Decisione**
|
||||
Il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Archivio certificati → utile per audit, rinviato a versioni future.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Implementazione più semplice e veloce.
|
||||
- Gli admin vedono solo il certificato attuale.
|
||||
- Va comunicato chiaramente ai giocatori che sostituire il file elimina quello precedente.
|
||||
|
||||
**Riesame**
|
||||
Se il CSI o il regolamento interno richiedono conservazione storica.
|
||||
@@ -0,0 +1,24 @@
|
||||
# DD-011 — Autenticazione reale prima del profilo amministrativo completo
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
|
||||
|
||||
**Decisione**
|
||||
Prima di completare il modulo Profilo Giocatore, introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Continuare solo con selezione da lista → inaccettabile per dati sensibili.
|
||||
- Lovable Auth → crea dipendenza da piattaforma che stiamo abbandonando.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Tutti dovranno fare login almeno una volta.
|
||||
- Gli admin useranno ruoli veri (`user_roles`), non una lista di nomi hardcoded.
|
||||
- È prerequisito per dashboard admin e export CSI.
|
||||
|
||||
**Riesame**
|
||||
Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali).
|
||||
@@ -0,0 +1,22 @@
|
||||
# DD-012 — Rimandare la migrazione degli ID giocatore
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
|
||||
|
||||
**Decisione**
|
||||
Per ora **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Migrare subito tutto a UUID → rischio alto di rompere presenze, voti, scout e notifiche.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Coesistono due modelli anagrafici fino a migration dedicata.
|
||||
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
|
||||
|
||||
**Riesame**
|
||||
Quando il modulo Profilo Giocatore è stabile e c’è tempo per una migration con checklist regressioni completa.
|
||||
@@ -0,0 +1,22 @@
|
||||
# DD-013 — Portabilità: l’app non deve dipendere da servizi esclusivi
|
||||
|
||||
**Data:** luglio 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La squadra potrebbe voler cambiare hosting, database o fornitore auth in futuro.
|
||||
|
||||
**Decisione**
|
||||
CrAPP deve poter girare su **Node.js + PostgreSQL standard**. Niente funzionalità bloccate su servizi proprietari. I dati si accedono solo tramite moduli in `src/lib/`, non direttamente dai componenti.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Accettare lock-in per velocità → contrario alla lunga vita del progetto.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Supabase va bene perché è PostgreSQL e self-hostable.
|
||||
- Le API push e i job restano endpoint HTTP richiamabili da qualsiasi scheduler.
|
||||
|
||||
**Riesame**
|
||||
Se si adotta un servizio che viola questa regola.
|
||||
@@ -0,0 +1,16 @@
|
||||
# DD-014 — Convergenza schema database (eventi e presenze)
|
||||
|
||||
**Data:** —
|
||||
**Stato:** In valutazione
|
||||
|
||||
**Contesto**
|
||||
Esistono due modelli paralleli: tabelle “legacy” usate dall’app (`eventi_app`, `risposte_presenze`) e tabelle “nuove” con autenticazione e vincoli (`eventi`, `presenze`, `giocatori` UUID).
|
||||
|
||||
**Decisione proposta**
|
||||
Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
|
||||
|
||||
**Perché non ora**
|
||||
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
|
||||
|
||||
**Riesame previsto**
|
||||
Dopo che il modulo Profilo Giocatore sarà stabile, con migration e test dedicati.
|
||||
@@ -0,0 +1,30 @@
|
||||
# DD-015 — Rosa anagrafica: da codice hardcoded a database
|
||||
|
||||
**Data:** 3 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La lista giocatori viveva nel codice sorgente (`src/lib/crapp-data.ts`). Il database aveva già
|
||||
`giocatori_squadra` (migration M1) popolata ma non letta da nessuna schermata tranne
|
||||
`/benvenuto` e `/admin`: «Aggiungi giocatore» e «Disattiva giocatore» della dashboard non
|
||||
avevano effetto su Squadra, Presenze, Pagelle, Badge e Scout, mantenendo gli stessi ID finché
|
||||
non si farà DD-012.
|
||||
|
||||
**Decisione**
|
||||
`useRosa()` (e con lei `useIo`, `useObiettivi`) legge ora `giocatori_squadra` tramite
|
||||
`useGiocatoriSquadra()`, filtrando solo i giocatori `attivo`. Tutti i punti che prima
|
||||
importavano la lista statica (`convocatiEvento`, `compleanniEventi`, `completaTurni`,
|
||||
`csvScoutMatch`, i widget di voto/scout/palloni, le due route API che mandano push) sono stati
|
||||
agganciati allo stesso hook o, lato server, a `leggiGiocatoriSquadra()`
|
||||
(`src/lib/giocatori-squadra.server.ts`, stesso pattern di `eventi.server.ts`).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un giocatore aggiunto o disattivato dalla dashboard admin ora si riflette ovunque, non solo
|
||||
in `/benvenuto` e `/admin`.
|
||||
- `giocatori_squadra` non ha ancora una colonna per la data di nascita: per i 17 giocatori
|
||||
storici resta quella di `crapp-data.ts` (`nascitaPerId`, lookup per id); un giocatore
|
||||
aggiunto dopo la migrazione non ha nascita nota finché la colonna non esiste. Follow-up da
|
||||
aprire quando serve davvero.
|
||||
- `src/lib/crapp-data.ts` resta come seed storico e fallback (`rosaFallback()`), non più come
|
||||
fonte viva.
|
||||
@@ -0,0 +1,45 @@
|
||||
# DD-016 — Schema dati Profilo Giocatore (F0)
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle preesistenti già operative.
|
||||
|
||||
**Decisione**
|
||||
Si introducono **due nuove tabelle additive**:
|
||||
|
||||
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
|
||||
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
|
||||
|
||||
Regole vincolanti:
|
||||
|
||||
1. **`giocatori_squadra` diventa progressivamente la source of truth** per l’anagrafica squadra. Durante la transizione, `crapp-data.ts` resta come **fallback** se il database non è disponibile o i dati non sono ancora migrati.
|
||||
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
|
||||
3. I file (documento identità, certificato; la foto tessera è stata tolta da DD-044) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
|
||||
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
|
||||
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
|
||||
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Estendere la tabella `giocatori` UUID → conflitto con ID operativi del codice e rischio di regressioni.
|
||||
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
|
||||
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità.
|
||||
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
|
||||
- Modificare le tabelle preesistenti per aggiungere FK verso il profilo → viola DD-004 e DD-012.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
|
||||
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
|
||||
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
|
||||
- Lo storico certificati non viene conservato (coerente con DD-010).
|
||||
- Le migration M1–M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
|
||||
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
|
||||
|
||||
**Riesame**
|
||||
|
||||
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
|
||||
- Quando si pianifica la convergenza verso UUID (DD-012, dopo che il profilo sarà stabile).
|
||||
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
|
||||
@@ -0,0 +1,37 @@
|
||||
# DD-017 — L'amministratore può compilare i dati al posto del giocatore
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
|
||||
|
||||
**Decisione**
|
||||
Dalla dashboard amministratore, un admin può:
|
||||
|
||||
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
|
||||
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
|
||||
3. **scollegare** un account da un profilo, liberando lo slot.
|
||||
|
||||
Restano fuori, e non cambiano:
|
||||
|
||||
- i **file** (documento, certificato; la foto tessera è stata tolta da DD-044): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
|
||||
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
|
||||
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
|
||||
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
|
||||
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
|
||||
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
|
||||
- Il completamento del profilo smette di essere un indicatore di _chi ha risposto_ e diventa un indicatore di _quali dati mancano_, chiunque li abbia inseriti.
|
||||
|
||||
**Riesame**
|
||||
|
||||
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
|
||||
- Se serve tracciare _chi_ ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
|
||||
@@ -0,0 +1,25 @@
|
||||
# DD-018 — Collegamento automatico giocatore↔account per email
|
||||
|
||||
**Data:** settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
DD-016 regola 2 prevedeva che, al primo accesso, il giocatore scegliesse manualmente il proprio slot libero da un elenco (`/benvenuto`). In pratica ogni giocatore ha un'email nota (o presto nota), quindi far scegliere un nome da una lista è un passaggio superfluo e un rischio: un giocatore può selezionare per errore lo slot di un compagno, e nulla nel flusso lo impedisce a livello di prodotto.
|
||||
|
||||
**Decisione**
|
||||
Al primo accesso, `giocatori_squadra` viene interrogata per email (case-insensitive, tramite la nuova colonna `email`) invece di mostrare un elenco di slot liberi. Se l'email dell'account Google corrisponde a una riga libera, il collegamento avviene automaticamente. Se non corrisponde a nessuna riga (email non ancora nota, o nessun profilo per quella persona), l'utente vede solo un messaggio d'errore che invita a contattare un amministratore, con un pulsante per uscire e riprovare con un altro account — nessuna selezione manuale di ripiego. Le email sono popolate via migration (`m5_email_giocatori_squadra`) per la rosa iniziale; un'interfaccia in `/admin` per impostarle su nuovi giocatori è arrivata poco dopo (vedi "Alternative scartate"). Il trigger `enforce_giocatori_squadra_update` (DD-016) viene esteso per richiedere anche la corrispondenza email, non solo lo slot libero: il vincolo resta nel database, non solo nella UI.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mantenere la selezione manuale come ripiego quando l'email non trova corrispondenza → scartata: vanificherebbe la garanzia "ognuno collega solo il proprio profilo" e reintrodurrebbe il rischio di scelta errata che questa decisione vuole eliminare.
|
||||
- Un'interfaccia admin per scrivere l'email dei giocatori → rimandata al momento della decisione, poi implementata nel form "Aggiungi giocatore" di `/admin` (`src/routes/admin.tsx`): serviva per collegare i giocatori aggiunti a metà stagione senza passare da una nuova migration ogni volta.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le righe senza email nota restano bloccate — nessuno può collegarle, nemmeno per errore — finché un admin non la imposta da `/admin` (o, per la rosa iniziale, una migration). Da settembre 2026 tutta la rosa attiva ha l'email registrata.
|
||||
- `slotLiberi` (funzione ed elenco "slot liberi" in `/benvenuto`) è stato rimosso: non aveva più chiamanti in produzione dopo il cambio.
|
||||
- Un utente che accede con l'account Google sbagliato resta bloccato su `/benvenuto` finché non esce e riprova con l'account giusto.
|
||||
|
||||
**Riesame**
|
||||
|
||||
- Se in futuro serve un'assistenza admin diretta dal flusso di login invece che da `/admin`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# DD-019 — Il branch dei commit lo decide l'utente
|
||||
|
||||
**Data:** 4 settembre 2026
|
||||
**Stato:** Accettata — sostituisce [DD-003](DD-003.md)
|
||||
|
||||
**Contesto**
|
||||
DD-003 prevedeva di lavorare su `develop` e portare su `main` solo dopo i test. Nella pratica
|
||||
è successo il contrario: `main` è arrivato a 43 commit di vantaggio su `develop`, che è rimasto
|
||||
fermo. Una regola che nessuno segue è peggio di nessuna regola, perché rende inaffidabile tutto
|
||||
il resto del documento — e con più assistenti AI in gioco il rischio vero non era il branch
|
||||
sbagliato, ma un agente che committa o pusha per conto suo.
|
||||
|
||||
**Decisione**
|
||||
È l'utente a dire su quale branch va un commit. L'assistente può **consigliare** un branch
|
||||
dedicato quando la modifica è rischiosa o parallela ad altro lavoro, ma non cambia branch, non
|
||||
committa, non fa push e non apre PR di propria iniziativa. In assenza di indicazioni si lavora
|
||||
dove si trova il repository, di fatto `main`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Tenere DD-003 e riallineare `develop` → si sarebbe rotta di nuovo alla prima fretta.
|
||||
- Dismettere `develop` → si perderebbero le preview Vercel, utili quando servono davvero.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- `main` è insieme produzione e branch di lavoro: ogni commit deve lasciare l'app funzionante,
|
||||
quindi la rete di sicurezza sono i test (vedi DD-020), non il branch.
|
||||
- `develop` esiste ancora ma è indietro: la sua preview Vercel non rappresenta lo stato attuale
|
||||
finché non viene riallineata.
|
||||
|
||||
**Riesame**
|
||||
Se il team cresce oltre una persona che scrive codice, o se un lavoro lungo ha bisogno di stare
|
||||
fuori produzione per più di qualche giorno.
|
||||
@@ -0,0 +1,31 @@
|
||||
# DD-020 — Una funzione modificata senza test non è finita
|
||||
|
||||
**Data:** 4 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Con `main` come branch di lavoro (DD-019) non c'è più un ambiente di prova tra il codice e i
|
||||
giocatori. La suite in `test/` esisteva già ma scriverla era di fatto facoltativo, e i difetti
|
||||
trovati dai test sono arrivati a posteriori (la sessione Scout Live che non scadeva mai, le
|
||||
serie di presenze ferme a zero per settimane).
|
||||
|
||||
**Decisione**
|
||||
Chi aggiunge o modifica una funzione scrive o aggiorna il test nello stesso lavoro, e i test
|
||||
devono essere verdi prima di consegnare. Non si commenta un test che fallisce né si indebolisce
|
||||
un'asserzione per farla passare: se il comportamento voluto è cambiato, si aggiorna il test
|
||||
dicendo perché.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Test solo sui moduli critici → il confine «critico» si sposta a ogni fretta.
|
||||
- Introdurre un framework di test → la suite bun con `node:assert` funziona e non aggiunge
|
||||
dipendenze (vedi [test/README.md](../../test/README.md)).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- La logica di dominio va tenuta separabile dagli hook (`*-core.ts`), altrimenti non è
|
||||
testabile in `test/unit/` senza rete.
|
||||
- Le modifiche costano un po' di più; le regressioni in produzione costano di più.
|
||||
|
||||
**Riesame**
|
||||
Se comparisse un ambiente di staging stabile che rende superflua parte della copertura.
|
||||
@@ -0,0 +1,40 @@
|
||||
# DD-021 — Molle interrompibili al posto delle animazioni a durata fissa
|
||||
|
||||
**Data:** 5 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il movimento era fatto con `@keyframes` CSS e transizioni a durata fissa. Funzionava, ma
|
||||
nessuna di quelle animazioni può essere interrotta: se l'utente tocca o scorre a metà, la
|
||||
sequenza va avanti per conto suo, e per ripartire deve prima finire. Mancava del tutto
|
||||
qualsiasi gesto: il calendario si cambiava solo con due frecce.
|
||||
|
||||
Contemporaneamente 43 componenti su 45 in `src/components/ui/` non erano importati da nessuna
|
||||
parte, e con loro ~45 dipendenze (tutti i `@radix-ui/*`, `recharts`, `react-hook-form`,
|
||||
`date-fns`, `embla`, `cmdk`, …): superficie di aggiornamento e di sicurezza pagata a vuoto.
|
||||
|
||||
**Decisione**
|
||||
Aggiungere **una** libreria di animazione — `motion` — e toglierne ~45 inutilizzate. I
|
||||
parametri stanno in `src/lib/molla.ts` e sono i due di Apple (_Designing Fluid Interfaces_):
|
||||
rimbalzo e durata, non massa/rigidità/smorzamento. `molla.ui` (nessun sorpasso) è il default;
|
||||
`molla.slancio` si usa **solo** dopo un gesto che portava già inerzia.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Tenere solo CSS con easing `linear()` e View Transitions → copre le comparse, non i gesti:
|
||||
niente handoff di velocità, niente ripartenza dal valore corrente.
|
||||
- GSAP → più grande e orientato alla timeline, cioè al modello prescritto che stiamo lasciando.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le animazioni partono dal valore _a schermo_: un dato che cambia a metà transizione non
|
||||
produce salti.
|
||||
- Il movimento è ora codice JavaScript: senza JS non c'è comparsa (l'app già non funziona
|
||||
senza, per auth e dati).
|
||||
- `src/lib/motion.ts` resta solo per il movimento ridotto e i coriandoli.
|
||||
- `src/components/ui/` non è più una libreria: aggiungere una primitiva shadcn significa
|
||||
installarla, non pescarla da lì.
|
||||
|
||||
**Riesame**
|
||||
Se il peso del bundle client diventasse un problema misurato, o se il web recuperasse
|
||||
nativamente l'interrompibilità (`ScrollTimeline` e `linear()` sono un primo passo).
|
||||
@@ -0,0 +1,32 @@
|
||||
# DD-022 — L'app è solo chiara
|
||||
|
||||
**Data:** 5 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
`styles.css` conteneva un blocco `.dark` completo che non veniva mai applicato: nessun
|
||||
interruttore, nessun `prefers-color-scheme`. Peggio, era incoerente. In `.dark` l'accento
|
||||
diventava grigio-blu — il rosso del brand spariva — e mancavano del tutto `--success`,
|
||||
`--warning`, `--info`, `--training`, i metalli dei badge, i due gradienti e le due ombre. Un
|
||||
terzo stato: presente, sbagliato, morto.
|
||||
|
||||
**Decisione**
|
||||
CrAPP è un'app solo chiara. Il blocco `.dark` è rimosso e `:root` dichiara
|
||||
`color-scheme: light`, così anche i controlli nativi restano coerenti.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Completare il tema scuro → è lavoro vero (gradienti, ombre, i due colori dei metalli, le
|
||||
superfici traslucide) per una richiesta che nessuno ha fatto.
|
||||
- Lasciare il blocco lì «per dopo» → un tema mai attivato non si accorge di rompersi.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Chi riaprirà il tema scuro parte da zero, ma da zero onesto: la palette chiara ha ora
|
||||
contrasti verificati e i token con suffisso `-testo` per i colori che come testo non
|
||||
reggono.
|
||||
- La barra di stato (`theme-color`) e lo splash del manifest sono allineati al fondo chiaro.
|
||||
|
||||
**Riesame**
|
||||
Se arriva una richiesta reale dalla squadra, o se si gioca in palestre al buio abbastanza
|
||||
spesso da rendere il tema scuro una funzione e non un vezzo.
|
||||
@@ -0,0 +1,59 @@
|
||||
# DD-023 — Ogni scrittura è limitata a chi la fa
|
||||
|
||||
**Data:** 6 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Le tabelle preesistenti sono nate con policy `USING (true)` per `anon, authenticated`. M4
|
||||
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
|
||||
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
|
||||
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app` →
|
||||
201, `DELETE` → 200. Qualsiasi giocatore loggato poteva svuotare il calendario della squadra
|
||||
o riscrivere il voto pagella di un altro, parlando direttamente con PostgREST — senza
|
||||
passare dall'interfaccia, che quei pulsanti glieli nasconde.
|
||||
|
||||
Il permesso viveva quindi solo nei componenti (`useIsAdmin()`, `io.id`), cioè nel posto che
|
||||
un attaccante non usa.
|
||||
|
||||
**Decisione**
|
||||
Le policy rispecchiano quello che l'interfaccia già fa. Tre gruppi:
|
||||
|
||||
- **solo admin**: `eventi_app`. Nell'app li scrive unicamente la rotta `/eventi`, che è già
|
||||
riservata agli amministratori.
|
||||
- **solo la propria riga**: `risposte_presenze`, `cacche_partita` (per `giocatore_id`),
|
||||
`pagelle_voti`, `mvp_voti`, `badge_social_voti` (per `votante_id`). L'admin resta incluso,
|
||||
perché DD-017 gli riconosce già il diritto di agire al posto del giocatore.
|
||||
- **invariate**: `turni_palloni` e le tre tabelle scout. Nell'interfaccia non hanno nessun
|
||||
gate — il turno palloni se lo passa chiunque, e chiunque può aprire lo Scout Live —
|
||||
quindi stringerle sarebbe una funzionalità nuova, non una messa in sicurezza.
|
||||
|
||||
L'identità è lo slot di `giocatori_squadra` collegato all'account, espresso con lo stesso
|
||||
`EXISTS` che usano le policy dei profili — la funzione `mio_giocatore_id()` di M2 è stata
|
||||
rimossa dalla migration di correzione e non si reintroduce. Regge perché `io.id` non è una
|
||||
scelta libera: dopo DD-011 e DD-018 il `localStorage` viene forzato sullo slot collegato
|
||||
all'account (`benvenuto.tsx`), e senza slot non si entra.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Lasciare tutto aperto e documentarlo → si può difendere per una squadra di venti persone
|
||||
che si conoscono, ma non regge il primo account che passa di mano o il primo telefono
|
||||
perso, e rende ogni bug indistinguibile da un dispetto.
|
||||
- Controllare i permessi nelle route server → CrAPP scrive dal client con supabase-js. Ci
|
||||
vorrebbe un livello API che oggi non esiste, per ottenere quello che la RLS fa da sola.
|
||||
- Stringere anche palloni e scout → cambierebbe come funziona la squadra, e nessuno l'ha
|
||||
chiesto.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un giocatore che non ha ancora collegato lo slot non scrive più niente: l'`EXISTS` non
|
||||
trova nessuna riga. È lo stesso muro di `/benvenuto`, ora applicato anche al database.
|
||||
- La lettura resta aperta a tutti gli autenticati, pagelle comprese: chi interroga PostgREST
|
||||
può ancora vedere **chi** ha votato cosa. L'anonimato delle pagelle è una scelta di
|
||||
interfaccia, non una garanzia del database, e questa migration non lo cambia.
|
||||
- I test in `test/integration/permessi.test.ts` diventano la definizione eseguibile di questa
|
||||
tabella dei permessi.
|
||||
|
||||
**Riesame**
|
||||
Se l'anonimato delle pagelle deve diventare reale (servirebbe una vista aggregata e la
|
||||
chiusura della lettura riga per riga), o se turni e scout acquistano un gate
|
||||
nell'interfaccia: allora le loro policy devono seguirlo.
|
||||
@@ -0,0 +1,60 @@
|
||||
# DD-024 — Le route che avvisano la squadra chiedono le credenziali
|
||||
|
||||
**Data:** 6 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Le route in `src/routes/api/public/` girano con la service role e saltano la RLS: DD-023 non
|
||||
le tocca. Nessuna di loro faceva un controllo di accesso — cercando `authorization` in quella
|
||||
cartella l'unico header era lo User-Agent con cui `csi.ts` chiama il portale CSI. Chiunque
|
||||
conoscesse l'URL poteva quindi far suonare i telefoni di tutta la squadra:
|
||||
`promemoria-palloni` accetta perfino una POST con il corpo vuoto.
|
||||
|
||||
La difesa apparente delle altre due — «serve un id evento valido» — non è una difesa: l'id è
|
||||
`e` + il timestamp in base 36 (`nuovoIdEvento()`), compare negli URL che la squadra si
|
||||
scambia, ed è elencabile da qualsiasi utente loggato.
|
||||
|
||||
Il danno non è furto di dati: i testi delle notifiche li costruisce il server. È molestia e
|
||||
consumo della quota push. Non è però una ragione per lasciare la porta aperta.
|
||||
|
||||
**Decisione**
|
||||
Le tre route che inviano notifiche chiedono le credenziali, con due controlli diversi perché
|
||||
i chiamanti sono diversi (`src/lib/auth-route.server.ts`):
|
||||
|
||||
- `apri-sondaggio` e `sollecita-presenze` → `richiediAdmin`: token della sessione Supabase
|
||||
verificato con `auth.getUser`, poi ruolo `admin` letto da `user_roles`, la stessa fonte di
|
||||
`src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il
|
||||
controllo viene **prima** della validazione dell'input, così la risposta non rivela
|
||||
nemmeno se un evento esiste.
|
||||
- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito
|
||||
subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025).
|
||||
|
||||
`csi`, `push-config` e `push-subscribe` restano aperte: le chiama il browser prima del login,
|
||||
dove qualsiasi segreto sarebbe pubblico. (`push-messaggio` esisteva per lo stesso motivo ed è
|
||||
sparita con DD-026.)
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Un segreto condiviso anche per le due route dell'app → finirebbe nel bundle JavaScript,
|
||||
cioè pubblico.
|
||||
- Il middleware `requireSupabaseAuth` già presente nel repository → è
|
||||
`createMiddleware({ type: "function" })`, protegge le server function di TanStack Start. In
|
||||
CrAPP `createServerFn` non compare da nessuna parte: quel file non è mai stato eseguito,
|
||||
e non si applica comunque alle route in `src/routes/api/`.
|
||||
- Fidarsi dell'id evento come credenziale → è un timestamp in un URL condiviso.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo
|
||||
stesso controllo sul ruolo.
|
||||
- I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()`
|
||||
(`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si
|
||||
spedisce un token scaduto.
|
||||
- `api.test.ts` non può più verificare la validazione dell'input di `sollecita-presenze`,
|
||||
che ora sta dietro all'accesso: quel pezzo si è spostato in
|
||||
`test/integration/permessi-route.test.ts`, che gira sullo stack locale perché ha bisogno
|
||||
di utenti veri.
|
||||
|
||||
**Riesame**
|
||||
Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada
|
||||
naturale e questi controlli vanno riletti alla sua luce.
|
||||
@@ -0,0 +1,54 @@
|
||||
# DD-025 — Il promemoria palloni lo manda l'admin, per un evento
|
||||
|
||||
**Data:** 6 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
`promemoria-palloni` era disegnata per un cron quotidiano: calcolava chi è di turno **oggi** e
|
||||
gli mandava una push. DD-024 l'aveva chiusa con un segreto condiviso, coerente con quel
|
||||
disegno. Ma nel repository non c'è nessun cron, e `docs/modules/palloni.md` lo annotava già
|
||||
come «da verificare lato hosting»: nei fatti quel promemoria non è mai partito. Una route che
|
||||
funziona e che nessuno chiama.
|
||||
|
||||
Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio
|
||||
(«Nessun cron: l'invio è manuale», CHANGELOG).
|
||||
|
||||
**Decisione**
|
||||
Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il
|
||||
riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i destinatari di
|
||||
**quell'evento** — chi deve prendere i palloni e chi deve riportarli — invece della giornata
|
||||
corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e
|
||||
`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono.
|
||||
|
||||
Il testo dell'avviso viaggia dentro la push, cifrato nel payload (DD-026): il service worker
|
||||
lo mostra così com'è, quindi un avviso mandato il martedì per il sabato arriva col testo
|
||||
giusto.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Un pulsante «manda il promemoria di oggi» in Dashboard → rispecchia la route com'era, ma
|
||||
premuto un martedì qualsiasi risponderebbe «inviate: 0» e sembrerebbe rotto. L'admin
|
||||
ragiona per evento, non per giornata.
|
||||
- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in
|
||||
ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che
|
||||
non esiste.
|
||||
- Calcolare il testo al volo sulla giornata corrente → funziona solo se l'avviso parte il
|
||||
giorno stesso, cioè proprio il vincolo da cui volevamo uscire.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme
|
||||
il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo
|
||||
avanti rispetto alla realtà, dove non partiva mai.
|
||||
- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`.
|
||||
- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun
|
||||
ambiente.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron
|
||||
torna utile, e con lui il segreto: DD-024 descrive già come farlo.
|
||||
|
||||
---
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 affianca al pulsante un invio automatico (poi ridotto a uno solo, 3 ore
|
||||
prima, da DD-042), per gli eventi con un incaricato confermato; il pulsante e la route restano.
|
||||
@@ -0,0 +1,44 @@
|
||||
# DD-026 — Il testo della notifica viaggia dentro la push
|
||||
|
||||
**Stato:** accettata · **Data:** settembre 2026
|
||||
|
||||
**Contesto**
|
||||
Le push partivano senza payload: il service worker, appena svegliato, chiedeva a
|
||||
`/api/public/push-messaggio` che cosa mostrare. Ad app aperta funzionava; ad app chiusa e
|
||||
telefono bloccato non arrivava niente. È lo scenario per cui il browser dà al worker pochi
|
||||
secondi di vita: una fetch verso un endpoint che interroga Supabase è esattamente ciò che non
|
||||
riesce a chiudersi in tempo, e senza `showNotification` non compare nulla. `Urgency: high` e
|
||||
un timeout di 3 secondi sul recupero del testo avevano attenuato il sintomo, non la causa.
|
||||
|
||||
**Decisione**
|
||||
Titolo e testo viaggiano cifrati nel corpo della push (`aes128gcm`, RFC 8188/8291) con le
|
||||
chiavi `p256dh` e `auth` già salvate in `push_subscriptions`. Il service worker legge
|
||||
`event.data.json()` e mostra la notifica: zero rete, zero attesa.
|
||||
|
||||
Cadono di conseguenza la route `push-messaggio`, la coda `promemoria_push` (con la sua
|
||||
scadenza a 12 ore), `messaggioPalloniOggi()` e il timeout nel service worker: tutti pezzi che
|
||||
esistevano solo per rimediare al payload vuoto. Tutti e tre i mittenti
|
||||
(`apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`) avevano già il testo pronto
|
||||
prima di inviare.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Usare la libreria `web-push` → dipende da `node:crypto`, e il build nitro ha come target
|
||||
Cloudflare. La cifratura sta in ~40 righe di Web Crypto, già disponibile ovunque.
|
||||
- Allungare ancora il timeout della fetch → allunga anche il tempo in cui il worker può
|
||||
morire prima di mostrare qualcosa. Il problema era la fetch, non la sua durata.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- La notifica non dipende più dalla rete al momento del risveglio, e nemmeno da una funzione
|
||||
serverless che risponda in fretta a freddo.
|
||||
- Sparisce l'unico punto in cui chiunque conoscesse un endpoint push poteva leggere il
|
||||
messaggio destinato a quel dispositivo.
|
||||
- Il payload sta sotto i 4 KB: i testi attuali sono ampiamente dentro, ma un messaggio molto
|
||||
lungo andrebbe accorciato.
|
||||
- La tabella `promemoria_push` resta nel database, ora inutilizzata: va rimossa con una
|
||||
migrazione quando si tocca lo schema.
|
||||
|
||||
**Riesame**
|
||||
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
|
||||
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
|
||||
@@ -0,0 +1,52 @@
|
||||
# DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
|
||||
|
||||
**Stato:** accettata · **Data:** 8 settembre 2026
|
||||
|
||||
**Contesto**
|
||||
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
|
||||
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
|
||||
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
|
||||
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
|
||||
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
|
||||
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
|
||||
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
|
||||
|
||||
**Decisione**
|
||||
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
|
||||
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
|
||||
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
|
||||
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
|
||||
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
|
||||
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
|
||||
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
|
||||
convocazione o dopo la chiusura.
|
||||
|
||||
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
|
||||
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
|
||||
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
|
||||
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
|
||||
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
|
||||
dall'audit che ha originato questa decisione.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
|
||||
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
|
||||
alla policy non-admin, li esclude naturalmente.
|
||||
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
|
||||
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
|
||||
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
|
||||
comportamento: tutta la rosa resta votabile, come prima.
|
||||
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
|
||||
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
|
||||
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
|
||||
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
|
||||
davvero presente, non solo convocato, per MVP/pagelle/badge social.
|
||||
|
||||
**Riesame**
|
||||
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
|
||||
non solo in UI.
|
||||
@@ -0,0 +1,50 @@
|
||||
# DD-028 — Soglia minima di campione per Media voto e MVP in home
|
||||
|
||||
**Data:** 9 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media
|
||||
voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario:
|
||||
`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10
|
||||
mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema
|
||||
che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al
|
||||
badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()`
|
||||
assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore
|
||||
votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA`
|
||||
(5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura
|
||||
`mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020).
|
||||
La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il
|
||||
badge.
|
||||
- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e
|
||||
`mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita
|
||||
(`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già
|
||||
esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare
|
||||
nessuno, nemmeno in assenza di concorrenza.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati
|
||||
dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata
|
||||
per il gap trovato in audit.
|
||||
- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo
|
||||
(un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto
|
||||
per l'MVP.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio
|
||||
`mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro,
|
||||
perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico
|
||||
persistito a parte.
|
||||
- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la
|
||||
soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già
|
||||
noto in [pagelle.md](../modules/pagelle.md).
|
||||
|
||||
**Riesame**
|
||||
Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o
|
||||
se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra.
|
||||
@@ -0,0 +1,77 @@
|
||||
# DD-029 — Cancellare un evento pulisce a cascata i dati collegati
|
||||
|
||||
**Data:** 9 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
|
||||
nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco
|
||||
eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente
|
||||
smette di contare. Il problema è un livello sotto: `useEliminaEvento()`
|
||||
(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle
|
||||
collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`,
|
||||
`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai
|
||||
avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database.
|
||||
|
||||
Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo
|
||||
dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno
|
||||
passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile)
|
||||
erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle
|
||||
tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane.
|
||||
|
||||
**Decisione**
|
||||
Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app`
|
||||
cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`)
|
||||
nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non
|
||||
in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su
|
||||
PostgREST, non solo chi passa dal bottone dell'app.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito:
|
||||
`mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/
|
||||
`badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non
|
||||
l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica
|
||||
disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti.
|
||||
- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta
|
||||
aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non
|
||||
protegge chi scrive/cancella direttamente su PostgREST.
|
||||
- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati
|
||||
reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente
|
||||
perché quelle righe sono già invisibili a ogni calcolo attuale.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto)
|
||||
ripulisce automaticamente tutte le tabelle collegate.
|
||||
- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel
|
||||
database: il trigger previene il problema da qui in avanti, non ripulisce lo storico.
|
||||
- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento:
|
||||
scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano
|
||||
tutte.
|
||||
|
||||
**Aggiornamento (9 settembre 2026, stesso giorno)** — la bonifica dello storico prevista sopra
|
||||
come "riesame" è stata fatta subito dopo, migration `m15_bonifica_dati_evento_orfani`: righe
|
||||
orfane in `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`,
|
||||
`scout_live`, `scout_partite` identificate confrontando `evento_id` con `eventi_app`. Per
|
||||
`mvp_voti`/`pagelle_voti`/`badge_social_voti` il confronto si applica **solo** ai `match_id`
|
||||
nel formato id evento CrAPP (`^e[0-9a-z]+$`, quello di `nuovoIdEvento()`): i vecchi voti su id
|
||||
Scout (prefisso `s` + timestamp decimale) o CSI (numerico o `data-squadra-squadra`) non sono
|
||||
orfani, sono dati storici legittimi mai collegati a un evento CrAPP (vedi contesto sopra), e la
|
||||
migration non li tocca. Verificato manualmente sul database locale prima di applicarla: un voto
|
||||
di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è
|
||||
stato rimosso.
|
||||
|
||||
**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di
|
||||
sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration
|
||||
`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione
|
||||
`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app),
|
||||
coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica
|
||||
su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve
|
||||
richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di
|
||||
manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch
|
||||
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).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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()`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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 (dal 29/09/2026 non più: DD-039) 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. [DD-046](DD-046.md) aggiunge l'avviso per chi non ha caricato il certificato.
|
||||
|
||||
**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. _Superata da [DD-046](DD-046.md):
|
||||
anche i mancanti hanno un avviso, rosso._
|
||||
|
||||
**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~~ → superato da DD-041: vede anche il proprio avviso personale.
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,76 @@
|
||||
# DD-036 — Notifiche email via Gmail, da un worker Docker sull'host di casa
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Le notifiche di CrAPP arrivano solo con la push (che dipende da Chrome vivo in background,
|
||||
vedi i limiti in `docs/modules/notifiche.md`) e nel centro notifiche in-app, che si vede solo
|
||||
aprendo l'app. Chi non ha attivato la push o ha un telefono aggressivo sul risparmio
|
||||
energetico non riceve nulla ad app chiusa. Serve un canale che arrivi comunque, **gratis**, ai
|
||||
giocatori registrati, che sono tutti su Gmail perché l'accesso è con Google. L'app gira su
|
||||
Vercel, che non esegue container né processi sempre attivi.
|
||||
|
||||
**Decisione**
|
||||
Le email partono da un **worker Docker sull'host di casa** che legge una coda su Supabase e
|
||||
invia tramite `smtp.gmail.com` con un account Gmail dedicato e una password per app.
|
||||
|
||||
- La coda è `notifiche_email_coda`, riempita da un trigger su `notifiche_utente`: ogni
|
||||
notifica già esistente, di qualunque tipo e per qualunque destinatario (admin compresi,
|
||||
con la stessa logica degli altri), diventa anche una mail. Le sorgenti di M17 non cambiano,
|
||||
non ne nascono di nuove e l'app non conosce le email. Il certificato in scadenza resta
|
||||
fuori: è una card in Home, non una notifica (DD-035).
|
||||
- Il worker fa solo polling **in uscita** con la service role: nessuna porta aperta sull'host.
|
||||
- La coda è una tabella a parte, con RLS senza policy, perché la policy `UPDATE` di
|
||||
`notifiche_utente` lascerebbe a un client la possibilità di rimettere in coda una mail.
|
||||
- Canale **opt-out**: acceso di default per tutti, con un interruttore per account in
|
||||
Profilo → Opzioni. La preferenza sta in una tabella nuova, `preferenze_utente`, una riga
|
||||
per account (non per slot); l'assenza di riga vale «acceso».
|
||||
- Specifica completa in [notifiche-email.md](../modules/notifiche-email.md); installazione e
|
||||
gestione in [WORKER_EMAIL.md](../WORKER_EMAIL.md).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Inviare direttamente dal proprio IP con un MTA (Postfix) → scartata: porta 25 bloccata dai
|
||||
provider residenziali, IP nelle blocklist, niente reverse DNS; le mail finirebbero in spam o
|
||||
sarebbero rifiutate.
|
||||
- Relay Brevo o Resend → scartati per ora: il piano gratuito funziona ma per una buona
|
||||
consegna richiede un dominio con SPF/DKIM/DMARC, che oggi non c'è. Restano l'alternativa
|
||||
se servirà un mittente della squadra.
|
||||
- Supabase Edge Function con `pg_cron` e un relay → scartata su richiesta esplicita di un
|
||||
servizio dockerizzato sull'host; resta la strada senza host acceso, ma dipende comunque da un
|
||||
relay esterno.
|
||||
- Vercel Cron → scartato: sul piano gratuito gira al massimo una volta al giorno, troppo poco
|
||||
per i promemoria a 24h e 3h.
|
||||
- Chiamare l'host dall'app (webhook verso casa) → scartato: richiederebbe un IP pubblico o un
|
||||
tunnel e una superficie d'attacco in più; il polling in uscita non ne ha.
|
||||
- Colonne di stato sulla riga di `notifiche_utente` → scartate per la RLS spiegata sopra.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Costo zero, ma il canale email dipende da un host acceso: se è spento le mail arrivano in
|
||||
ritardo (mai perse). Consegna _almeno una volta_: in caso di crash tra invio e
|
||||
registrazione può arrivare un doppione.
|
||||
- Il tetto di circa 500 destinatari al giorno di Gmail è largo per una squadra, ma è un limite
|
||||
reale: con molte squadre o riepiloghi frequenti andrebbe rivisto.
|
||||
- Nuova migration (`m22_notifiche_email`), nuova cartella `mailer/` (worker bun con la sola
|
||||
dipendenza `nodemailer`, separato dall'app) e un segreto in più (la service role sull'host).
|
||||
L'app non guadagna nessuna dipendenza e le route di M17 non cambiano.
|
||||
- Il certificato in scadenza resta fuori dalle email: non è una notifica (DD-035), quindi non
|
||||
passa da `notifiche_utente`. Renderlo una notifica sarebbe una decisione a parte.
|
||||
- Il tetto giornaliero è una finestra mobile di 24 ore, `MAIL_LIMITE_GIORNO` (400 di default,
|
||||
sotto i circa 500 di Gmail).
|
||||
- Le email dei giocatori si usano per un secondo scopo oltre al login: da qui l'opt-out e
|
||||
l'indicazione in ogni mail su come disattivarlo.
|
||||
- Una tabella in più, `preferenze_utente`, pensata per accogliere in futuro anche le
|
||||
preferenze per tipo di notifica senza toccare `giocatori_squadra`.
|
||||
- In produzione dal 29/09/2026 (migration e worker su un Raspberry Pi). Dalla prova reale: la
|
||||
prima mail, da un account Gmail nuovo, è arrivata ma nella cartella spam, e la password per app
|
||||
non era disponibile finché l'account aveva le sole passkey come secondo passaggio (dettagli in
|
||||
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app)).
|
||||
|
||||
**Riesame**
|
||||
Se serve un mittente col dominio della squadra, se le mail finiscono in spam anche dopo qualche
|
||||
giorno di uso, se si supera il tetto giornaliero di Gmail, o se l'host di casa si rivela poco
|
||||
affidabile: passare a un relay con dominio e, in quel caso, valutare Edge Function più `pg_cron`
|
||||
al posto del container.
|
||||
@@ -0,0 +1,55 @@
|
||||
# DD-037 — I promemoria evento ricordano di essere già stati generati
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
I promemoria a 24 e a 3 ore (M17, DD-030) evitavano i doppioni con `ON CONFLICT DO NOTHING` sul
|
||||
vincolo `UNIQUE (giocatore_id, evento_id, tipo)` di `notifiche_utente`: la riga della notifica
|
||||
era l'unica traccia che il promemoria fosse già stato generato. Il giocatore però può eliminarla
|
||||
(swipe o ×, DD-030). Senza più la riga, al giro successivo del job (ogni ora per il promemoria a
|
||||
24 ore, ogni 15 minuti per quello a 3 ore, finché l'evento non inizia) il promemoria veniva
|
||||
rigenerato come non letto. Con il canale email (DD-036) ogni rigenerazione accodava anche una
|
||||
mail nuova: un giocatore che eliminava un promemoria ne riceveva una ogni ora fino all'inizio
|
||||
dell'evento. Il difetto è stato trovato controllando la logica il 29/09/2026, il giorno in cui il
|
||||
worker email è entrato in produzione, e riprodotto sul database locale.
|
||||
|
||||
**Decisione**
|
||||
Un registro separato, `promemoria_eventi_generati` (migration `m23_promemoria_gia_generati`), con
|
||||
una riga per `(giocatore_id, evento_id, tipo)` scritta dalla funzione `genera_promemoria_eventi()`.
|
||||
La notifica in-app si crea solo per le righe **appena registrate**, quindi eliminarla non fa
|
||||
ripartire nulla. Il registro è indipendente da `notifiche_utente`, senza policy per i client, e
|
||||
sparisce con l'evento o con il giocatore (`ON DELETE CASCADE`). I promemoria già presenti al
|
||||
momento della migration sono stati registrati come generati, altrimenti la prima notifica
|
||||
eliminata dopo la migration sarebbe tornata come prima. Il vincolo `UNIQUE` di `notifiche_utente`
|
||||
resta come seconda difesa. Cron, criteri di selezione, testi e destinatari sono invariati.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Non eliminare davvero la notifica (nasconderla con un flag) → scartata: cambia il significato
|
||||
dell'eliminazione per tutte le sorgenti, tocca la RLS e le query del client, e la tabella
|
||||
crescerebbe di righe che il giocatore non vede più.
|
||||
- Togliere l'eliminazione dai soli promemoria, lasciando «segna come letta» → scartata: a un
|
||||
giocatore che vuole ripulire l'elenco resterebbero notifiche non eliminabili, incoerenti con
|
||||
le altre.
|
||||
- Un flag «già generato» sulla riga dell'evento → scartata: il promemoria è per giocatore, e chi
|
||||
viene aggiunto ai convocati dopo deve comunque riceverlo.
|
||||
- Confrontare `creato_il` o lo stato «letta» per capire se ricrearlo → scartata: non distingue
|
||||
una notifica mai generata da una eliminata.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Eliminare un promemoria lo fa sparire per sempre, e non parte nessuna mail in più. Il numero
|
||||
massimo di mail automatiche per giocatore ed evento resta due (24 e 3 ore).
|
||||
- Una tabella in più, che cresce di una riga per giocatore, evento e tipo e si svuota con
|
||||
l'evento.
|
||||
- Se un evento viene spostato dopo che il promemoria è partito, non ne parte uno nuovo per la
|
||||
data nuova: è lo stesso comportamento di prima, ora esplicito.
|
||||
- `test/integration/promemoria-eventi.test.ts` è la definizione eseguibile: il promemoria
|
||||
eliminato non ricompare, non riaccoda mail, i due tipi sono indipendenti, un convocato aggiunto
|
||||
dopo lo riceve comunque, il registro non è leggibile né scrivibile da un client e sparisce con
|
||||
l'evento. Sul database con la funzione vecchia il test fallisce.
|
||||
|
||||
**Riesame**
|
||||
Se gli eventi potessero essere spostati con frequenza e servisse un nuovo promemoria per la data
|
||||
nuova, il registro va agganciato anche alla data di inizio, oltre a giocatore, evento e tipo.
|
||||
@@ -0,0 +1,68 @@
|
||||
# DD-038 — Ogni notifica ha tre canali: la push dei promemoria parte dal worker
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il requisito è che ogni notifica arrivi su tutti e tre i canali: push, centro notifiche in-app ed
|
||||
email. Due casi non lo rispettavano ([catalogo](../modules/notifiche.md#catalogo-delle-notifiche)):
|
||||
|
||||
- i **promemoria a 24 e 3 ore** nascono dentro il database (job `pg_cron`, M17), che non può
|
||||
firmare né cifrare una push Web (VAPID, RFC 8291): arrivavano solo in-app e per email;
|
||||
- il **sondaggio pre-partita** partiva solo come push e non scriveva in `notifiche_utente`, quindi
|
||||
non compariva in-app e, dato che le email nascono da quella tabella (DD-036), non arrivava per
|
||||
email.
|
||||
|
||||
Le altre notifiche (messaggio dello staff, turno palloni, sollecito presenze) partono dall'app su
|
||||
Vercel e avevano già i tre canali.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Sondaggio pre-partita:** la route `apri-sondaggio` scrive anche `notifiche_utente` (tipo
|
||||
nuovo `sondaggio_cacche`, un `upsert` per giocatore ed evento come le altre), per i giocatori
|
||||
attivi che non sono allenatori. La mail parte dal trigger di M22 senza altro lavoro. La push
|
||||
resta com'è. Il vincolo `CHECK` sul tipo si estende con la migration `m24`.
|
||||
2. **Push dei promemoria:** la manda il **worker `mailer/`**, con un secondo canale accanto alle
|
||||
email. Un trigger accoda ogni promemoria a 24 o 3 ore in `notifiche_push_coda` (stessa
|
||||
struttura di `notifiche_email_coda`); il worker prende il lotto con `FOR UPDATE SKIP LOCKED`,
|
||||
manda la push a tutti i dispositivi iscritti del giocatore e registra l'esito. Usa **lo stesso
|
||||
modulo dell'app** (`src/lib/webpush.server.ts`, senza dipendenze) invece di una copia: il
|
||||
container si costruisce dalla radice del repository. Le iscrizioni che rispondono 404 o 410
|
||||
vengono eliminate, come nelle route. Le push in coda da oltre 3 ore si scartano: un promemoria
|
||||
arrivato a evento iniziato è solo rumore.
|
||||
3. La push dei promemoria non dipende dall'interruttore «Email»: dipende solo dai dispositivi
|
||||
iscritti, come le altre push.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Far chiamare all'app dal database (`pg_net`, già abilitata) una route che manda la push →
|
||||
scartata: il database dipenderebbe dall'indirizzo pubblico di Vercel e da un segreto condiviso,
|
||||
e `pg_net` è un'estensione del provider (regola 4 di [PORTABILITA.md](../PORTABILITA.md)).
|
||||
- Vercel Cron → scartato: sul piano gratuito gira una volta al giorno, come già notato in DD-036.
|
||||
- Copiare il modulo push nel worker → scartata: due versioni da tenere allineate per codice che
|
||||
cifra e firma.
|
||||
- Mandare dal worker la push di tutte le notifiche → scartata: le altre partono già dall'app, si
|
||||
avrebbero push doppie.
|
||||
- Un unico stato di coda per email e push → scartata: le due consegne hanno esiti, tentativi e
|
||||
destinatari diversi (una push per dispositivo, un'email per giocatore).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il worker non fa più solo email: gestisce due canali (il documento operativo resta
|
||||
[WORKER_EMAIL.md](../WORKER_EMAIL.md)). Serve la chiave VAPID privata anche sull'host, un segreto in
|
||||
più. Senza le chiavi il worker parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
- La push dei promemoria dipende dall'host di casa acceso, come le email: se è spento parte alla
|
||||
ripartenza, ma non oltre le 3 ore dalla creazione.
|
||||
- Una tabella nuova (`notifiche_push_coda`) e una migration (`m24_push_promemoria_e_sondaggio`).
|
||||
- Il sondaggio compare nel centro notifiche e arriva per email ai giocatori attivi, non agli
|
||||
allenatori; la push continua ad andare a tutti i dispositivi iscritti tranne quelli degli
|
||||
allenatori, anche di giocatori non più attivi.
|
||||
- Le altre notifiche non cambiano canali e non ricevono push doppie.
|
||||
|
||||
**Aggiornamento (30 settembre 2026)** — DD-040 esclude dalle email le notifiche del turno palloni: per
|
||||
quei tipi i canali sono due (push e in-app).
|
||||
|
||||
**Riesame**
|
||||
Se l'host di casa si rivela poco affidabile per una notifica sensibile al tempo come la push, o se
|
||||
il database dovesse comunque chiamare l'app per altro, valutare di nuovo la chiamata dal database
|
||||
all'app.
|
||||
@@ -0,0 +1,40 @@
|
||||
# DD-039 — L'allenatore non riceve i promemoria degli eventi
|
||||
|
||||
**Data:** 29 settembre 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
DD-034 stabilì che l'allenatore riceve i promemoria a 24 e 3 ore prima di un evento, sempre, convocato
|
||||
o no (`giocatori_destinatari_evento()` riscritta da M21). Ma un promemoria serve a ricordare di
|
||||
esserci, e l'allenatore non può rispondere alle presenze, non è convocabile e non compare tra i
|
||||
partecipanti (DD-034). L'incoerenza si vede negli eventi extra-campo, come una cena di squadra:
|
||||
riceveva l'avviso (in-app, per email e, dopo M24, come push) senza poter dire se ci sarebbe stato né
|
||||
comparire nell'elenco.
|
||||
|
||||
**Decisione**
|
||||
I destinatari dei promemoria sono i **giocatori attivi**: i convocati, o tutta la rosa se
|
||||
`convocati` è vuoto. L'allenatore non li riceve, per nessun tipo di evento. Cambia solo la funzione
|
||||
SQL (migration `m25_promemoria_solo_giocatori`, `g.tipo = 'giocatore'` al posto di «o allenatore
|
||||
sempre»); cron, criteri di selezione, registro dei promemoria (DD-037), code email e push e testi
|
||||
restano invariati.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Toglierli solo per gli eventi extra-campo → scartata su indicazione esplicita di toglierli per
|
||||
gli eventi in generale: anche per un allenamento o una partita l'allenatore non è tra i
|
||||
partecipanti.
|
||||
- Farlo rispondere agli eventi extra-campo, così da tenere il promemoria → scartata: servono
|
||||
risposte presenze e regole del database per una categoria nuova, e il conteggio dei presenti.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le notifiche che l'allenatore riceve dal server sono i **messaggi dello staff** e nient'altro
|
||||
(sollecito, turno palloni, sondaggio e notifiche smart non lo riguardano già).
|
||||
- I promemoria già generati per un allenatore prima della migration non si toccano.
|
||||
- Un allenatore non è avvisato dell'inizio di un evento che gestisce: sa quando è perché lo ha
|
||||
creato lui o lo vede in Calendario.
|
||||
- Sostituisce la parte di DD-034 sui promemoria; il resto di DD-034 resta valido.
|
||||
|
||||
**Riesame**
|
||||
Se l'allenatore dovesse poter partecipare a certi eventi (per esempio la cena), si riapre insieme
|
||||
alla sua possibilità di rispondere.
|
||||
@@ -0,0 +1,94 @@
|
||||
# DD-040 — Promemoria con «oggi» o «domani», turno palloni e solleciti automatici
|
||||
|
||||
**Data:** 30 settembre 2026
|
||||
**Stato:** Accettata (implementata: migration `m26`, applicata in produzione il 30 settembre 2026, job, route e test). Il sollecito presenze è poi passato a un solo avviso a 24 ore da [DD-045](DD-045.md). Il turno palloni è poi passato a un solo avviso a 3 ore da [DD-042](DD-042.md).
|
||||
|
||||
**Contesto**
|
||||
Quattro notifiche su sei partivano da un pulsante premuto da un admin (DD-025 per i palloni): se nessuno
|
||||
lo premeva, nessuno veniva avvisato. I testi erano inoltre brevi e informali, e i promemoria non dicevano
|
||||
quando e dove. Si vuole che turno palloni e sollecito presenze partano anche da soli, a orari fissi prima
|
||||
dell'evento, e che i testi siano più formali e completi. I testi nuovi sono nel
|
||||
[catalogo](../modules/notifiche.md#testi-delle-notifiche).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Promemoria a 24 e 3 ore:** nuovo testo con data, ora e luogo. La parola nel titolo e nella riga
|
||||
«Data» è **«domani»** se la data dell'evento è quella di domani (fuso `Europe/Rome`), **«oggi»**
|
||||
altrimenti. Vale per entrambi i promemoria, quindi anche quello a 3 ore di un evento dopo la
|
||||
mezzanotte dice «domani». La parola la calcola la funzione SQL quando genera la notifica.
|
||||
2. **Turno palloni automatico.** Un job `pg_cron` ogni 15 minuti avvisa per gli eventi di tipo allenamento
|
||||
o partita, in tre tempi con finestre che non si sovrappongono: **12 ore** (tra 12 e 6 ore prima
|
||||
dell'inizio), **6 ore** (tra 6 e 3) e **3 ore** (meno di 3). Parte **solo se per l'evento c'è un
|
||||
incaricato confermato** in `turni_palloni`: le proposte automatiche di `completaTurni()` non salvate non
|
||||
contano. Gli avvisi sono due, con lo stesso testo nei tre tempi: «incarico assegnato» all'incaricato e
|
||||
«riconsegna» a chi aveva i palloni all'evento precedente, se è un'altra persona.
|
||||
- **Pulsante manuale.** «Avvisa chi è di turno» e la route `promemoria-palloni` **restano**: tipo
|
||||
`turno_palloni`, testo nuovo, push mandata dalla route e non dalla coda. È una notifica in più;
|
||||
riceverne due (automatica e manuale) va bene.
|
||||
- **Incaricato cambiato dopo un avviso.** Il nuovo incaricato riceve il suo avviso al giro successivo,
|
||||
con il tipo della finestra in cui cade. Chi aveva già ricevuto un avviso e non è più destinatario
|
||||
riceve **una sola volta** «Turno palloni: incarico revocato» (`turno_palloni_revocato`), se
|
||||
l'evento non è iniziato. Il job lo scopre confrontando i destinatari attuali con il **registro** degli
|
||||
avvisi automatici già generati (non con le notifiche, che il giocatore può aver eliminato), senza
|
||||
trigger su `turni_palloni`. Gli avvisi del pulsante non contano: possono riguardare una proposta non
|
||||
confermata.
|
||||
- **Niente email.** Gli avvisi palloni (automatici, revoca e pulsante) arrivano come push e nel centro
|
||||
notifiche, **non per email**: il trigger `notifiche_utente_accoda_email` salta i tipi
|
||||
`turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. È un'eccezione a
|
||||
DD-038 («ogni notifica ha tre canali») solo per questi tipi; l'interruttore «Email» non cambia.
|
||||
3. **Sollecito presenze automatico in tre tempi.** Lo stesso job manda il sollecito **24 ore** (tra 24 e
|
||||
12 ore prima), **12 ore** (tra 12 e 6) e **6 ore** (meno di 6) prima, al massimo una notifica per
|
||||
giocatore e per tipo. Lo riceve solo chi in quel momento non ha risposto o ha risposto «forse» ed è
|
||||
destinatario dell'evento: i convocati, o tutta la rosa attiva se `convocati` è vuoto, mai l'allenatore.
|
||||
Chi risponde presente, assente o in ritardo smette di riceverli; chi resta su «forse» li riceve tutti e
|
||||
tre. Un evento creato a meno di 12 ore dall'inizio riceve solo il sollecito della sua finestra.
|
||||
- **Pulsante manuale.** «Sollecita» e la route `sollecita-presenze` **restano**, anche per l'allenatore:
|
||||
tipo `sollecita_presenze`, push mandata dalla route, testo nuovo con in più la riga «Richiesta di:
|
||||
_Nome_». I destinatari sono **gli stessi dell'automatico** (convocati senza risposta o con «forse»);
|
||||
oggi la route guarda invece tutti i giocatori attivi.
|
||||
4. **Stessa infrastruttura dei promemoria** (DD-037, DD-038): le notifiche automatiche nascono nel
|
||||
database, il registro `promemoria_eventi_generati` impedisce i doppioni anche se il giocatore le
|
||||
elimina, la push la manda il worker dalla coda `notifiche_push_coda`, l'email (quando c'è) nasce dal
|
||||
trigger su `notifiche_utente`. Tipi nuovi: `sollecita_presenze_24h`, `_12h`, `_6h`;
|
||||
`turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato`. Il `turno_palloni` del pulsante resta.
|
||||
5. **Migration `m26`:** estende i `CHECK` sui tipi di `notifiche_utente` e del registro, il trigger della
|
||||
coda push, il trigger della coda email (che salta i tipi dei palloni) e ridefinisce la funzione dei
|
||||
promemoria. Le funzioni nuove riusano `giocatori_destinatari_evento()`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere i pulsanti manuali (turno palloni e sollecito) una volta arrivati gli automatici → scartata su
|
||||
indicazione esplicita: servono all'admin per rimandare un avviso o sollecitare fuori dagli orari fissi.
|
||||
- Mandare tutti i solleciti (o gli avvisi palloni) a un evento creato tardi → scartata: più messaggi nel
|
||||
giro di pochi minuti sono rumore.
|
||||
- «Domani» e «oggi» fissi nei due testi → scartata: il job delle 24 ore scatta anche per eventi che
|
||||
iniziano oggi, e quello delle 3 ore attraversa la mezzanotte.
|
||||
- Calcolare il testo nell'app invece che nel database → scartata: le notifiche nascono nei job `pg_cron`,
|
||||
che non chiamano l'app (DD-038).
|
||||
- Spostare nel worker il calcolo di evento precedente e successivo dei palloni → scartata: un secondo
|
||||
punto che scrive in `notifiche_utente`, per un vantaggio piccolo.
|
||||
- Ritirare l'avviso già consegnato al vecchio incaricato → scartata: per la push non si può, e in-app
|
||||
confonde. Si manda la revoca.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Cambia la regola «quattro notifiche su sei dipendono da un gesto manuale»: restano solo manuali il
|
||||
messaggio dello staff e il sondaggio pre-partita; per turno palloni e sollecito gli automatici si
|
||||
aggiungono ai pulsanti, e un giocatore può ricevere lo stesso avviso due volte.
|
||||
- **Logica duplicata:** «evento precedente» e «evento successivo» per i palloni, in `palloni-core.ts`
|
||||
(usato dal pulsante e dalla schermata), sono riscritti in SQL per i job (`genera_avvisi_palloni_fascia`).
|
||||
Ordine comune: data, ora, id. Se si cambia una regola in una sola delle due versioni, pulsante e job danno
|
||||
risposte diverse: `notifiche-automatiche.test.ts` confronta testi e destinatari sugli stessi dati, anche a
|
||||
parità di data e ora.
|
||||
- `destinatariSollecito()` riceve anche i convocati e serve al sollecito manuale; un giocatore non
|
||||
convocato non è più sollecitato. `avvisiPalloniEvento()` resta come riferimento per i test finché il SQL
|
||||
non la sostituisce del tutto.
|
||||
- Se un incaricato revocato viene poi riassegnato non riceve un secondo avviso, perché il registro lo ha
|
||||
già generato per quella finestra: la revoca resta l'ultima notifica che ha letto.
|
||||
- Le mail automatiche per evento e giocatore sono al massimo **cinque** (due promemoria, tre solleciti),
|
||||
più quelle dei pulsanti (sollecito, sondaggio). Il turno palloni non ne manda.
|
||||
- Il testo dei promemoria già generati non cambia (il registro impedisce di rigenerarli).
|
||||
|
||||
**Riesame**
|
||||
Dopo la prima stagione di uso: se i solleciti o gli avvisi palloni risultano troppi, ridurre le
|
||||
finestre o escludere chi ha risposto «forse» più di una volta.
|
||||
@@ -0,0 +1,39 @@
|
||||
# DD-041 — Avviso certificati: il giocatore è avvisato un mese prima dello staff
|
||||
|
||||
**Data:** 1 ottobre 2026
|
||||
**Stato:** Accettata (modifica la soglia di [DD-035](DD-035.md) per il solo giocatore)
|
||||
|
||||
**Contesto**
|
||||
Con DD-035 giocatore e admin vedono l'avviso negli stessi 7 giorni prima della scadenza. Per
|
||||
il giocatore sono pochi: prenotare una visita di idoneità richiede più tempo (era il rischio
|
||||
indicato nel Riesame di DD-035). All'admin invece un avviso così anticipato riempirebbe la
|
||||
Home di nomi che non può ancora sollecitare con urgenza.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Il **giocatore** vede l'avviso giallo da **30 giorni** prima della scadenza
|
||||
(`GIORNI_AVVISO_CERTIFICATO_GIOCATORE`).
|
||||
2. L'**admin** resta a **7 giorni** prima (`GIORNI_AVVISO_CERTIFICATO`), invariato.
|
||||
3. Dopo la scadenza l'avviso è **nero** per entrambi, invariato.
|
||||
4. L'**admin che è anche giocatore** vede il proprio avviso **personale** (30 giorni,
|
||||
cliccabile) oltre a quello dello staff, dal quale il suo nome è tolto per non duplicarlo.
|
||||
5. Nessun altro aspetto di DD-035 cambia: calcolo al volo, niente push, stessi destinatari.
|
||||
|
||||
Esempio: scadenza il 5 ottobre 2026 → dal 5 settembre l'avviso giallo compare solo al
|
||||
giocatore; dal 28 settembre compare anche agli admin; dal 6 ottobre è nero per entrambi.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- «Un mese» come mese di calendario → la soglia varierebbe tra 28 e 31 giorni; 30 giorni fissi
|
||||
sono prevedibili e coincidono con l'esempio della richiesta.
|
||||
- Soglia unica a 30 giorni anche per l'admin → elenco dello staff pieno con settimane di
|
||||
anticipo, senza nulla da fare per l'admin che non può caricare il file (DD-017).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Il giocatore vede il conto alla rovescia «scade tra N giorni» con N fino a 30.
|
||||
- `avvisiCertificati()` riceve la soglia come parametro (default 7, cioè lo staff).
|
||||
|
||||
**Riesame**
|
||||
Se 30 giorni risultano ancora pochi o troppi, cambiare la costante; se serve una soglia
|
||||
regolabile dall'admin, vedi il Riesame di DD-035.
|
||||
@@ -0,0 +1,36 @@
|
||||
# DD-042 — Turno palloni: un solo avviso, 3 ore prima
|
||||
|
||||
**Data:** 1 ottobre 2026
|
||||
**Stato:** Accettata (migration `m27`, applicata in produzione il 1 ottobre 2026; modifica la parte «turno palloni» di [DD-040](DD-040.md))
|
||||
|
||||
**Contesto**
|
||||
DD-040 manda l'avviso del turno palloni a 12, 6 e 3 ore prima dell'evento: tre messaggi identici per
|
||||
chi deve portare i palloni e per chi deve prenderli. Sono troppi per un'informazione che non cambia.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. L'avviso automatico parte **una sola volta, 3 ore prima** dell'inizio (finestra «meno di 3 ore»), a
|
||||
chi deve portare i palloni («incarico assegnato») e a chi li deve prendere («riconsegna», se è
|
||||
un'altra persona). Testi, destinatari, push senza email e condizione dell'incaricato confermato
|
||||
restano quelli di DD-040.
|
||||
2. La revoca resta: chi è stato avvisato e poi non è più destinatario riceve una sola volta «incarico
|
||||
revocato». Un evento creato o assegnato a meno di 3 ore dall'inizio riceve l'avviso al giro
|
||||
successivo del job.
|
||||
3. **Migration `m27`:** `genera_avvisi_palloni()` chiama solo la fascia `turno_palloni_3h`. I tipi
|
||||
`turno_palloni_12h` e `_6h` **restano nei `CHECK`** e nella revoca, perché notifiche e righe di
|
||||
registro già generate li usano; non ne nascono di nuovi.
|
||||
4. Il pulsante «Avvisa chi è di turno» non cambia. I solleciti presenze non cambiano qui (poi ridotti a uno, DD-045).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate in produzione non sarebbero più
|
||||
valide e la revoca non vedrebbe più chi ha ricevuto quegli avvisi.
|
||||
- Tenere anche un avviso a 12 ore → scartata su indicazione esplicita: uno solo.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Chi non apre l'app nelle 3 ore prima può non vedere l'avviso; la push è il canale principale.
|
||||
- Per le notifiche palloni già generate con DD-040 non cambia nulla.
|
||||
|
||||
**Riesame**
|
||||
Se 3 ore risultano poche per procurarsi i palloni, alzare la finestra (una sola, mai più avvisi).
|
||||
@@ -0,0 +1,35 @@
|
||||
# DD-043 — Niente promemoria automatico a 24 e 3 ore prima degli eventi
|
||||
|
||||
**Data:** 1 ottobre 2026
|
||||
**Stato:** Accettata (migration `m28`, applicata in produzione il 1 ottobre 2026; sostituisce il promemoria di
|
||||
[DD-030](DD-030.md), [DD-037](DD-037.md) e [DD-040](DD-040.md))
|
||||
|
||||
**Contesto**
|
||||
Il promemoria a 24 e 3 ore prima di ogni evento non serve: la squadra ha già il calendario, il
|
||||
sollecito presenze (per chi non ha risposto) e l'avviso del turno palloni a 3 ore.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Il database **non genera più** `evento_promemoria_24h` ed `evento_promemoria_3h`, per nessun tipo di
|
||||
evento (allenamento, partita, extra-campo).
|
||||
2. La migration `m28` toglie solo i due job `pg_cron` (`promemoria-eventi-24h` e `-3h`).
|
||||
`genera_promemoria_eventi()`, i tipi nei `CHECK`, il registro `promemoria_eventi_generati` e la coda
|
||||
push restano: le notifiche già create continuano a esistere e rimettere il promemoria richiede solo
|
||||
un `cron.schedule`.
|
||||
3. Non cambiano: sollecito presenze, turno palloni (DD-042), messaggio dello staff, sondaggio.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Togliere il promemoria solo per gli allenamenti → scartata su indicazione esplicita: via per tutti.
|
||||
- Cancellare funzione, tipi e registro → scartata: vanno tenute le notifiche già generate e il lavoro per
|
||||
rimetterlo sarebbe molto più grande di un `cron.schedule`.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Meno notifiche per la squadra. Chi voleva il promemoria di una partita non lo riceve più.
|
||||
- Funzione, test e registro restano come codice non pianificato: sono ancora coperti da
|
||||
`promemoria-eventi.test.ts` e dagli altri test sulla funzione.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra sente la mancanza del promemoria (per esempio solo per le partite), rimettere i job o
|
||||
reintrodurlo per tipo di evento.
|
||||
@@ -0,0 +1,38 @@
|
||||
# DD-044 — Via la foto tessera dal profilo
|
||||
|
||||
**Data:** 1 ottobre 2026
|
||||
**Stato:** Accettata (migration `m29`, applicata in produzione il 1 ottobre 2026)
|
||||
|
||||
**Contesto**
|
||||
Il profilo amministrativo chiedeva al giocatore una foto formato tessera (DD-016), per il
|
||||
tesseramento CSI. Non serve più: non viene richiesta nel tesseramento e resta un dato personale
|
||||
da custodire.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Il profilo **non ha più la foto tessera**: sparisce il campo di caricamento dal Profilo e la
|
||||
voce «Foto» dalla scheda dell'admin.
|
||||
2. **Completamento del profilo** su tre sezioni: dati personali 34%, documento 33%, certificato 33%
|
||||
(prima 30/30/30/10). Il totale resta 100 e il widget «Completa il tuo profilo» sparisce con le
|
||||
tre sezioni complete.
|
||||
3. **Dati:** la migration `m29` elimina la colonna `profili_giocatore.foto_path`. I file
|
||||
`<giocatore_id>/foto.*` del bucket privato `profili-giocatore` vanno cancellati con l'API
|
||||
Storage (da SQL Storage lo vieta) prima della migration, perché dopo i path non sono più
|
||||
leggibili. **Non toccati:** l'avatar (bucket `avatar-giocatori`), il documento e il certificato.
|
||||
4. Ordine in produzione: **prima il rilascio del codice** (la versione precedente seleziona
|
||||
`foto_path` e darebbe errore), poi cancellazione dei file, poi `m29`.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Lasciare la colonna e nascondere solo l'interfaccia → scartata: resterebbe un dato personale
|
||||
senza uso e senza un campo da cui aggiornarlo.
|
||||
- Tenere i pesi 30/30/30 senza rinormalizzare → scartata: nessun profilo arriverebbe a 100%.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Cancellazione definitiva: le foto già caricate non si recuperano.
|
||||
- Il 1 ottobre 2026 sono state cancellate in produzione le 4 foto caricate, poi la colonna.
|
||||
- Le percentuali di completamento cambiano per tutti (chi aveva il 90% senza foto passa al 100%).
|
||||
|
||||
**Riesame**
|
||||
Se il comitato CSI dovesse richiedere di nuovo la foto, reintrodurla con una nuova decisione.
|
||||
@@ -0,0 +1,34 @@
|
||||
# DD-045 — Sollecito presenze: un solo avviso automatico, 24 ore prima
|
||||
|
||||
**Data:** 1 ottobre 2026
|
||||
**Stato:** Accettata (migration `m30`, applicata in produzione il 1 ottobre 2026; modifica la parte «sollecito presenze»
|
||||
di [DD-040](DD-040.md))
|
||||
|
||||
**Contesto**
|
||||
DD-040 manda il sollecito presenze a 24, 12 e 6 ore prima dell'evento: fino a tre messaggi per chi non
|
||||
risponde o resta su «forse». Ne basta uno.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Il sollecito automatico parte **una sola volta, 24 ore prima** (finestra «meno di 24 ore»), a chi in
|
||||
quel momento non ha risposto o ha risposto «forse» tra i destinatari dell'evento (convocati, o tutta
|
||||
la rosa attiva; mai l'allenatore). Un evento creato a meno di 24 ore dall'inizio lo riceve comunque,
|
||||
al giro successivo del job. Testo e canali (push, centro notifiche, email) restano quelli di DD-040.
|
||||
2. Il **pulsante «Sollecita»** e la route `sollecita-presenze` non cambiano: l'admin o l'allenatore possono
|
||||
sempre rimandarlo a mano, anche dopo l'automatico.
|
||||
3. **Migration `m30`:** `genera_solleciti_presenze()` chiama solo la fascia `sollecita_presenze_24h`. I tipi
|
||||
`sollecita_presenze_12h` e `_6h` restano nei `CHECK` per le notifiche già generate; non ne nascono di nuovi.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mandarlo a un orario più vicino (12 o 6 ore) → scartata su indicazione esplicita: 24 ore.
|
||||
- Togliere i tipi `12h` e `6h` dai `CHECK` → le righe già generate non sarebbero più valide.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Chi risponde «forse» o non risponde dopo il sollecito non riceve altri avvisi automatici: tocca al
|
||||
pulsante manuale.
|
||||
- Il massimo di mail automatiche per giocatore ed evento scende a una (il sollecito).
|
||||
|
||||
**Riesame**
|
||||
Se il tasso di risposta cala, valutare un secondo sollecito più vicino all'evento, solo per chi non ha mai risposto.
|
||||
@@ -0,0 +1,33 @@
|
||||
# DD-046 — Avviso certificati: anche chi non l'ha caricato
|
||||
|
||||
**Data:** 4 ottobre 2026
|
||||
**Stato:** Accettata (modifica [DD-035](DD-035.md): il certificato mancante non è più escluso dall'avviso)
|
||||
|
||||
**Contesto**
|
||||
L'avviso certificati di DD-035 guarda solo la scadenza e salta chi non ha mai caricato il
|
||||
certificato («è un problema diverso»). Per l'admin significa che chi non ha caricato nulla non
|
||||
compare in Home, pur essendo un caso più grave di uno scaduto.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Un giocatore in rosa **senza data o senza file** del certificato è **mancante**.
|
||||
2. L'**admin** vede la card rossa «Certificati mancanti» con l'elenco, **sopra** scaduti e in
|
||||
scadenza; il suo nome non compare se è anche giocatore.
|
||||
3. Il **giocatore** mancante vede la card rossa «Certificato mancante», cliccabile verso il tab
|
||||
documenti. Vale anche per l'admin che è giocatore.
|
||||
4. Nessuna soglia: l'avviso resta finché il certificato non è caricato. Il rosso è `destructive`,
|
||||
già nel tema; giallo e nero degli altri avvisi non cambiano.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Stesso nero degli scaduti → non distingue «scaduto» da «mai caricato», che è più grave.
|
||||
- Solo elenco admin → l'admin che è anche giocatore sarebbe l'unico a non vedere il proprio buco.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- `avvisiCertificati()` restituisce anche `mancanti`; `avvisiCertificatiUtente()` anche
|
||||
`personaleMancante`.
|
||||
- Un giocatore appena entrato in rosa compare tra i mancanti finché non carica il file.
|
||||
|
||||
**Riesame**
|
||||
Se i giocatori nuovi riempiono l'elenco senza motivo, aggiungere un periodo di tolleranza.
|
||||
@@ -0,0 +1,65 @@
|
||||
# DD-047 — Notifica di compleanno
|
||||
|
||||
**Data:** 6 ottobre 2026
|
||||
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
|
||||
|
||||
**Contesto**
|
||||
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
|
||||
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
|
||||
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
|
||||
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
|
||||
`notifiche_utente`, il worker `mailer/` manda push ed email.
|
||||
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
|
||||
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
|
||||
mancato si recupera all'ora dopo e non si genera mai due volte.
|
||||
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
|
||||
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
|
||||
nessun avviso.
|
||||
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
|
||||
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
|
||||
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
|
||||
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
|
||||
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
|
||||
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
|
||||
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
|
||||
senza i nomi degli altri.
|
||||
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
|
||||
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
|
||||
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
|
||||
`eventi_app`.
|
||||
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
|
||||
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
|
||||
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
|
||||
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
|
||||
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
|
||||
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
|
||||
chiede la issue.
|
||||
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
|
||||
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
|
||||
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
|
||||
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
|
||||
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
|
||||
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
|
||||
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
|
||||
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
|
||||
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
|
||||
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
|
||||
destinatari). Chi la inserisce prima delle 8 viene incluso.
|
||||
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
|
||||
- Il compleanno resta anche in Calendario, invariato.
|
||||
|
||||
**Riesame**
|
||||
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
|
||||
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
|
||||
@@ -0,0 +1,37 @@
|
||||
# DD-048 — Via numero e data tessera dalla dashboard
|
||||
|
||||
**Data:** 6 ottobre 2026
|
||||
**Stato:** Accettata (nessuna migration)
|
||||
|
||||
**Contesto**
|
||||
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
|
||||
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
|
||||
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
|
||||
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
|
||||
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
|
||||
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
|
||||
amministrativo: documento, certificato, dati personali.
|
||||
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
|
||||
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
|
||||
già inseriti restano a database.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
|
||||
già inseriti; si può fare dopo senza toccare l'interfaccia.
|
||||
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
|
||||
`0/N`, un'informazione falsa.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- L'admin non può più registrare né correggere la tessera dall'app.
|
||||
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
|
||||
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
|
||||
|
||||
**Riesame**
|
||||
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
|
||||
migration e togliere i due campi dal tipo e dalla lettura.
|
||||
@@ -0,0 +1,53 @@
|
||||
# DD-049 — Caratteri più grandi in tutta l'app
|
||||
|
||||
**Data:** 6 ottobre 2026
|
||||
**Stato:** Accettata (solo interfaccia, nessuna migration)
|
||||
|
||||
**Contesto**
|
||||
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
|
||||
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
|
||||
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
|
||||
|
||||
| Prima | Dopo |
|
||||
| ----------------- | ----- |
|
||||
| `text-xs` (12 px) | 13 px |
|
||||
| 13 px | 14 px |
|
||||
| 14 px | 15 px |
|
||||
| `text-sm` (14 px) | 15 px |
|
||||
| 15 px | 16 px |
|
||||
|
||||
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
|
||||
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
|
||||
|
||||
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
|
||||
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
|
||||
(conteggio sulla campanella).
|
||||
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
|
||||
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
|
||||
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
|
||||
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
|
||||
l'aspetto, non i testi né le regole su quando un avviso compare.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
|
||||
barre a pillola, e ogni schermata andrebbe ricontrollata.
|
||||
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
|
||||
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
|
||||
il testo andrebbe a capo o verrebbe troncato.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
|
||||
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
|
||||
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
|
||||
titoli di card.
|
||||
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
|
||||
|
||||
**Riesame**
|
||||
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
|
||||
contenitori stretti, senza toccare il resto.
|
||||
@@ -0,0 +1,73 @@
|
||||
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
|
||||
|
||||
**Data:** 8 ottobre 2026
|
||||
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
|
||||
|
||||
**Contesto**
|
||||
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
|
||||
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
|
||||
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
|
||||
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
|
||||
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
|
||||
evento). Scatta per **qualsiasi
|
||||
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
|
||||
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
|
||||
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
|
||||
modifica la fa un admin per conto suo.
|
||||
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
|
||||
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
|
||||
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
|
||||
ricevere notifiche, perché `notifiche_utente` è per slot.
|
||||
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
|
||||
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
|
||||
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
|
||||
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
|
||||
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
|
||||
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
|
||||
|
||||
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
|
||||
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
|
||||
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
|
||||
|
||||
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
|
||||
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
|
||||
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
|
||||
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
|
||||
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
|
||||
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
|
||||
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
|
||||
notifiche e cinque email.
|
||||
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
|
||||
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
|
||||
|
||||
**Fase di prova (conclusa)**
|
||||
|
||||
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
|
||||
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
|
||||
ora vale la regola del punto 2.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
|
||||
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
|
||||
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
|
||||
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
|
||||
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
|
||||
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
|
||||
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
|
||||
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
|
||||
[allenatore.md](../modules/allenatore.md#notifiche).
|
||||
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
|
||||
per evento, non togliere l'avviso.
|
||||
|
||||
**Riesame**
|
||||
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
|
||||
sono troppe o poche, spostare la soglia (una costante nella funzione).
|
||||
@@ -0,0 +1,110 @@
|
||||
# Registro delle decisioni di progetto
|
||||
|
||||
Ogni file di questa cartella è **una decisione** (`DD-XXX.md`): una scelta che ha influito sulla
|
||||
direzione del prodotto, sull'organizzazione del lavoro o su come l'app si evolve. Non descrive
|
||||
_come_ è fatto il codice (per quello esistono [ARCHITECTURE.md](../ARCHITECTURE.md) e
|
||||
[DATABASE.md](../DATABASE.md)): risponde a _perché abbiamo scelto così_, _cosa avevamo escluso_
|
||||
e _quando conviene riaprire la scelta_.
|
||||
|
||||
**Leggi solo le decisioni che ti servono**: cerca il tema nell'indice qui sotto e apri il file
|
||||
corrispondente. Non serve leggerle tutte.
|
||||
|
||||
## Come usare questo registro
|
||||
|
||||
Ogni decisione segue lo schema di [../_template-dd.md](../_template-dd.md):
|
||||
|
||||
| Campo | Significato |
|
||||
| ------------------------ | ------------------------------------------------------ |
|
||||
| **Data** | Quando la decisione è stata presa o confermata |
|
||||
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
|
||||
| **Contesto** | Quale problema o opportunità avevamo di fronte |
|
||||
| **Decisione** | Cosa abbiamo scelto di fare |
|
||||
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
|
||||
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
|
||||
| **Riesame** | Quando ha senso riconsiderarla |
|
||||
|
||||
**Quando aggiungere una voce**
|
||||
|
||||
- una scelta influisce su più moduli o su più release;
|
||||
- escludiamo un'alternativa non ovvia;
|
||||
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
|
||||
- cambiamo una decisione precedente.
|
||||
|
||||
**Quando non serve**
|
||||
|
||||
- dettagli implementativi locali;
|
||||
- scelte estetiche minori;
|
||||
- bugfix o correzioni puntuali.
|
||||
|
||||
**Come registrare una nuova decisione**
|
||||
|
||||
Copiare [../_template-dd.md](../_template-dd.md) in `docs/decisions/DD-XXX.md` con il primo ID
|
||||
libero (gli ID non si riusano e non si rinumerano), poi aggiungere la riga all'indice qui sotto.
|
||||
Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimanda a quella nuova.
|
||||
|
||||
## Indice
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](DD-005.md) | Mobile-first |
|
||||
| [DD-006](DD-006.md) | AI solo se utile |
|
||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||
| [DD-022](DD-022.md) | App solo chiara |
|
||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
||||
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
| ID | Titolo |
|
||||
| ------------------- | --------------------- |
|
||||
| [DD-014](DD-014.md) | Convergenza schema DB |
|
||||
|
||||
**Sostituite**
|
||||
|
||||
| ID | Titolo |
|
||||
| ------------------- | --------------------- |
|
||||
| [DD-003](DD-003.md) | Branch main / develop |
|
||||
+16
-14
@@ -1,7 +1,7 @@
|
||||
# Modulo — Ruolo Allenatore
|
||||
|
||||
**Stato:** implementato
|
||||
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
|
||||
**Decisione:** [DD-034](../decisions/DD-034.md)
|
||||
**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`,
|
||||
@@ -55,8 +55,8 @@ Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
|
||||
| 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.
|
||||
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico,
|
||||
tesseramento CSI.
|
||||
|
||||
### Completamento profilo
|
||||
|
||||
@@ -97,7 +97,7 @@ nessuna schermata:
|
||||
| Dove | Cosa si nasconde all'allenatore |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
|
||||
| `/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) |
|
||||
@@ -114,7 +114,7 @@ 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.
|
||||
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono un sollecito automatico 24 ore prima dell'evento, ma il pulsante resta.
|
||||
|
||||
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
|
||||
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
|
||||
@@ -125,14 +125,16 @@ risposte presenze altrui, gestione dei ruoli.
|
||||
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
|
||||
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
|
||||
|
||||
| Notifica | L'allenatore la riceve? | Note |
|
||||
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
|
||||
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
|
||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito. |
|
||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||
| Notifica | L'allenatore la riceve? | Note |
|
||||
| ------------------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria a 24 e 3 ore prima di un evento (rimosso, DD-043) | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
|
||||
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
|
||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
||||
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
|
||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||
|
||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
|
||||
@@ -164,7 +166,7 @@ giocatore).
|
||||
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
|
||||
| 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 |
|
||||
| Documento, certificato | 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ì |
|
||||
|
||||
+34
-266
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
|
||||
|
||||
## Elenco badge
|
||||
|
||||
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||
Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
|
||||
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||
informazioni verso l'interfaccia.
|
||||
@@ -93,7 +93,6 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||
|
||||
@@ -104,13 +103,12 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
|
||||
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||
|
||||
| id | nome | condizione esatta |
|
||||
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
| id | nome | condizione esatta |
|
||||
| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
|
||||
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||
|
||||
@@ -140,259 +138,31 @@ voti ricevuti).
|
||||
|
||||
## Copertura test
|
||||
|
||||
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
|
||||
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
|
||||
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
|
||||
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
|
||||
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
|
||||
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
|
||||
end-to-end completa.
|
||||
|
||||
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
|
||||
`badges.test.ts`) sull'output di altri moduli:
|
||||
|
||||
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
|
||||
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
|
||||
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
|
||||
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
|
||||
bronzo". Più la soglia minima di voti (vedi sotto).
|
||||
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
|
||||
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
|
||||
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
|
||||
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
|
||||
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
|
||||
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
|
||||
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
|
||||
tabella.
|
||||
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
|
||||
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
|
||||
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
|
||||
gli altri badge da tabella.
|
||||
|
||||
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
|
||||
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
|
||||
voto/turni/presenze
|
||||
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
|
||||
|
||||
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
|
||||
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
|
||||
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
|
||||
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
|
||||
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
|
||||
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
|
||||
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
|
||||
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
|
||||
|
||||
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
|
||||
copertura di questo badge):
|
||||
|
||||
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
|
||||
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
|
||||
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
|
||||
(`mvp_no_autovoto`).
|
||||
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
|
||||
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
|
||||
votato non convocati vengono rifiutati.
|
||||
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
|
||||
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
|
||||
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
|
||||
deve contare come vittoria.
|
||||
|
||||
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
|
||||
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
|
||||
|
||||
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
|
||||
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
|
||||
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
|
||||
(`pagelle_no_autovoto`), già presente prima di questa sessione.
|
||||
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
|
||||
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
|
||||
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
|
||||
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
|
||||
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
|
||||
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
|
||||
applichi le soglie normali sopra soglia.
|
||||
|
||||
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
|
||||
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
|
||||
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
|
||||
|
||||
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
|
||||
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
|
||||
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
|
||||
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
|
||||
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
|
||||
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
|
||||
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
|
||||
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
|
||||
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
|
||||
pur avendo già una conferma.
|
||||
|
||||
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
|
||||
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
|
||||
M13 — vedi sotto):
|
||||
|
||||
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
|
||||
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
|
||||
filtro sui convocati, solo partite/allenamenti).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
|
||||
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
|
||||
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
|
||||
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
|
||||
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
|
||||
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
|
||||
scritta per un evento senza convocazione **non conta comunque**, perché
|
||||
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
|
||||
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
|
||||
funzione pura che il badge consuma, non solo in UI.
|
||||
|
||||
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
|
||||
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
|
||||
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
|
||||
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
|
||||
pura.
|
||||
|
||||
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
|
||||
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
|
||||
convocati che non spezzano la serie di chi non era coinvolto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
|
||||
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
|
||||
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
|
||||
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
|
||||
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
|
||||
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
|
||||
non riparte da 1).
|
||||
|
||||
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
|
||||
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
|
||||
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
|
||||
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
|
||||
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
|
||||
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
|
||||
database, non possono garantire.
|
||||
|
||||
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
|
||||
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
|
||||
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
|
||||
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
|
||||
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
|
||||
azzera), e un evento futuro che non entra ancora nel calcolo.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
|
||||
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
|
||||
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
|
||||
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
|
||||
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
|
||||
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
|
||||
|
||||
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
|
||||
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
|
||||
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
|
||||
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
|
||||
|
||||
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
|
||||
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
|
||||
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
|
||||
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
|
||||
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
|
||||
esattamente al confine (3 infortuni, 5 ritardi).
|
||||
|
||||
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
|
||||
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
|
||||
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
|
||||
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
|
||||
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
|
||||
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
|
||||
|
||||
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
|
||||
`cacche.test.ts` (già completo su `giornateTop`).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
|
||||
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
|
||||
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
|
||||
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
|
||||
|
||||
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
|
||||
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
|
||||
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
|
||||
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
|
||||
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
|
||||
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
|
||||
|
||||
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
|
||||
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
|
||||
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
|
||||
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
|
||||
`mvpVintiPerGiocatore()`/`mediePagelle()` → `statoBadge()` che userebbe l'app.
|
||||
|
||||
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
|
||||
segreto a combinare due statistiche indipendenti (`serieConferme()` e
|
||||
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
|
||||
|
||||
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
|
||||
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
|
||||
bastano".
|
||||
- Integration (`npx supabase start` richiesto):
|
||||
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
|
||||
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
|
||||
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
|
||||
già accumulate (le due statistiche restano indipendenti anche a database).
|
||||
|
||||
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
|
||||
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
|
||||
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
|
||||
meccanismo:
|
||||
|
||||
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
|
||||
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
|
||||
vince in `m1` e `m2` → `{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
|
||||
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
|
||||
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
|
||||
mischiano in `badgeSocialVinti()`.
|
||||
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
|
||||
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
|
||||
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
|
||||
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
|
||||
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
|
||||
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
|
||||
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
|
||||
tocca il conteggio delle altre 4 nella stessa partita.
|
||||
Tutti e 16 i badge hanno test unit (`test/unit/badges.test.ts`, `badge-social.test.ts`) e di
|
||||
integration end-to-end sul database locale (`test/integration/*-badge.test.ts`,
|
||||
`badge-social.test.ts`, più `scritture` e `permessi`). La logica pura sta in `badges.ts`; i test
|
||||
verificano soglie inclusive (1→bronzo, soglia decimale 6.49 ≠ 6.5), confini dei badge segreti e
|
||||
la soglia minima di voti della Pagella (`VOTI_MINIMI_PAGELLA`). Il dettaglio test per test lo
|
||||
dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
||||
|
||||
### Riepilogo per badge
|
||||
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -419,11 +189,9 @@ meccanismo:
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
|
||||
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
|
||||
rimossi il 2026-10-06.
|
||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||
@@ -439,7 +207,7 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg
|
||||
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
||||
conseguenza.
|
||||
|
||||
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
**Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
||||
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
||||
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||
@@ -464,7 +232,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||
- Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
|
||||
ora che le serie sono calcolate.
|
||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||
|
||||
@@ -24,9 +24,13 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
||||
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.
|
||||
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
|
||||
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
|
||||
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
|
||||
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
|
||||
selezionato.
|
||||
- **`/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)):
|
||||
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
|
||||
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
|
||||
@@ -39,7 +43,7 @@ true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||
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)).
|
||||
([DD-033](../decisions/DD-033.md)).
|
||||
|
||||
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||
@@ -49,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
|
||||
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||
`eventi_app`.
|
||||
|
||||
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
|
||||
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
|
||||
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
|
||||
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
|
||||
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
|
||||
luogo, e né la card né le notifiche lo mostrano.
|
||||
|
||||
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||
@@ -68,10 +79,10 @@ dalle risposte solo sulla scheda evento (vedi `presenze.md`).
|
||||
|
||||
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
|
||||
(`m14_pulizia_dati_evento_cancellato`,
|
||||
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
|
||||
[DD-029](../decisions/DD-029.md))
|
||||
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
|
||||
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
|
||||
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
|
||||
orfane nel database (bonificate una tantum da M15/M16, vedi [DATABASE.md](../DATABASE.md)).
|
||||
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
|
||||
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
|
||||
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
|
||||
|
||||
@@ -50,7 +50,7 @@ mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione s
|
||||
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)).
|
||||
a 2026/27 ([DD-032](../decisions/DD-032.md)).
|
||||
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Questo documento descrive **cosa fa** il canale email. Come si installa, si configura e si
|
||||
diagnostica il worker sta in [WORKER_EMAIL.md](../WORKER_EMAIL.md); le motivazioni delle scelte in
|
||||
[DD-036](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
|
||||
[DD-036](../decisions/DD-036.md).
|
||||
|
||||
## Obiettivo
|
||||
|
||||
@@ -49,8 +49,9 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
||||
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, promemoria a 24 ore e a
|
||||
3 ore, turno palloni, sollecito presenze, sondaggio pre-partita. Un tipo nuovo che in futuro finisse in quella tabella
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **Fa eccezione il turno palloni** (DD-040, dalla
|
||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||
**non** vengono inviate: il trigger non ha backfill.
|
||||
|
||||
|
||||
+122
-40
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
@@ -31,40 +31,90 @@ particolare il certificato in scadenza **non** genera push né voci nel centro n
|
||||
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
|
||||
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
|
||||
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, quindi
|
||||
la colonna Email coincide con la colonna In-app).
|
||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | ----- |
|
||||
| 1 | **Promemoria a 24 ore** — «Promemoria: _titolo_», corpo «data alle ora» | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
|
||||
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Tocca a te prendere i palloni» / «Porta i palloni» | Un admin, con il pulsante nella pagina dell'evento. Solo eventi di tipo allenamento o partita. Route `promemoria-palloni`, tipo `turno_palloni`. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | sì |
|
||||
| 5 | **Sollecito presenze** — «Manca la tua risposta» | Un admin o un allenatore, con il pulsante nella pagina dell'evento. Route `sollecita-presenze`, tipo `sollecita_presenze`. | I giocatori attivi (mai gli allenatori) che non hanno ancora risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
||||
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
||||
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
|
||||
|
||||
Tutte le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3-6) la mandano le route dell'app, quelle
|
||||
dei promemoria (1 e 2) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Le righe 4 e 5 riscrivono la notifica esistente se l'admin preme di nuovo il pulsante
|
||||
per lo stesso evento, e lo stesso fa il sondaggio (vedi «Centro notifiche in-app»).
|
||||
Le notifiche generate dal server (righe 1-6, 8 e 9) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||
automatiche (4, 5, 8 e 9; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||
|
||||
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
|
||||
> applicate in produzione (`m26` il 30/09/2026; `m27` il 01/10/2026, che porta il turno palloni a un solo
|
||||
> avviso 3 ore prima, DD-042).
|
||||
|
||||
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
|
||||
|
||||
### Testi delle notifiche
|
||||
|
||||
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
|
||||
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
|
||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
|
||||
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
|
||||
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
|
||||
|
||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
|
||||
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
|
||||
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
|
||||
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
|
||||
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
|
||||
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
|
||||
|
||||
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
|
||||
dall'emoji. Non entrano nel centro notifiche e non generano email.
|
||||
|
||||
| Situazione | Titolo | Corpo |
|
||||
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
|
||||
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
|
||||
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
|
||||
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
|
||||
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
|
||||
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
|
||||
|
||||
### Cosa non genera nessuna notifica
|
||||
|
||||
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
|
||||
dell'inizio (DD-030).
|
||||
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
||||
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
|
||||
- Assenze e infortuni.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047).
|
||||
|
||||
### Comportamenti da conoscere
|
||||
|
||||
Sono l'effetto attuale della logica, non scelte documentate altrove:
|
||||
Sono l'effetto attuale della logica, non scelte documentate altrove. I punti sui **promemoria 1 e 2**
|
||||
descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessun job la chiama
|
||||
(DD-043); valgono solo se i job vengono ripianificati.
|
||||
|
||||
- **I promemoria (1 e 2) non distinguono il tipo di evento:** scattano per allenamenti, partite ed
|
||||
eventi extra-campo (per esempio una cena). Il turno palloni (4), invece, esiste solo per
|
||||
@@ -81,27 +131,47 @@ Sono l'effetto attuale della logica, non scelte documentate altrove:
|
||||
- **Il registro non segue le modifiche all'evento:** se un evento viene spostato dopo che il
|
||||
promemoria è partito, non ne parte uno nuovo per la data nuova. Chi viene aggiunto ai convocati
|
||||
dopo riceve invece il suo promemoria, perché il registro è per giocatore.
|
||||
- **Le notifiche 3, 4 e 5, una volta eliminate, non tornano da sole:** riappaiono solo se un admin
|
||||
rimanda il messaggio o ripreme il pulsante.
|
||||
- **Le notifiche eliminate non tornano da sole:** la 3 e la 6 riappaiono solo se un admin
|
||||
rimanda il messaggio o riapre il sondaggio; quelle automatiche (1, 2, 4 e 5) non tornano affatto,
|
||||
perché il registro ricorda di averle già generate.
|
||||
- **Un evento già iniziato non genera più promemoria.**
|
||||
- **Quattro notifiche su sei dipendono da un gesto manuale** (3, 4, 5 e 6): se nessuno preme il
|
||||
pulsante, nessuno viene avvisato. Gli unici automatismi sono i due promemoria.
|
||||
- **Per ogni evento i giocatori ricevono al massimo due mail automatiche** (24 e 3 ore prima),
|
||||
più quelle dei pulsanti manuali.
|
||||
- **Due notifiche su sei dipendono solo da un gesto manuale** (3 e 6): se nessuno preme il pulsante,
|
||||
nessuno viene avvisato. Le altre quattro sono automatiche (DD-040); per turno palloni e sollecito il
|
||||
pulsante resta come aggiunta e può arrivare in più rispetto a quelle automatiche.
|
||||
- **Il turno palloni parte solo se l'incaricato è confermato** (DD-040): per una partita la
|
||||
proposta automatica di `completaTurni()` non salvata non basta. Se l'incaricato cambia dopo
|
||||
un avviso, il nuovo riceve il proprio al giro successivo e il precedente riceve una sola volta
|
||||
«Turno palloni: incarico revocato»; se viene poi riassegnato, non riceve un secondo avviso.
|
||||
- **Il sollecito automatico è uno solo, a 24 ore** (DD-045): parte per gli eventi che iniziano entro 24
|
||||
ore, a chi in quel momento non ha risposto o ha risposto «forse». Un evento creato a meno di 24
|
||||
ore dall'inizio lo riceve comunque, una volta. Chi risponde dopo non ne riceve altri; chi resta su
|
||||
«forse» non lo riceve una seconda volta. Altri solleciti solo col pulsante manuale.
|
||||
- **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
|
||||
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e
|
||||
agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se
|
||||
il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario.
|
||||
Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro.
|
||||
- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli
|
||||
altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato.
|
||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
| Cosa | Test |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
||||
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
@@ -155,7 +225,7 @@ copre in particolare le sessioni lunghe della webapp (vedi il
|
||||
L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un JWT VAPID
|
||||
(ECDSA P-256), cifra `{title, body}` per il dispositivo destinatario e fa una POST
|
||||
all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts`,
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts`.
|
||||
`promemoria-palloni.ts` e `apri-sondaggio.ts` .
|
||||
|
||||
Il testo viaggia **dentro** la push, cifrato in `aes128gcm` (RFC 8188/8291) con le chiavi del
|
||||
dispositivo: il service worker fa `event.data.json()` e mostra la notifica senza toccare la
|
||||
@@ -179,6 +249,9 @@ variabile d'ambiente.
|
||||
| `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 |
|
||||
|
||||
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
|
||||
anche per l'allenatore, `richiediGestoreEventi()`).
|
||||
|
||||
`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.
|
||||
@@ -224,11 +297,12 @@ non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico per
|
||||
`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).
|
||||
|
||||
Cinque sorgenti scrivono in `notifiche_utente`, mai il client:
|
||||
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
|
||||
job `pg_cron` come la 2, che scrive `turno_palloni_3h`/`_revocato` (`_12h`/`_6h` solo fino a `m26`) e `sollecita_presenze_24h` (`_12h`/`_6h` solo fino a `m29`); i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
|
||||
|
||||
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
|
||||
2. **Promemoria evento (rimosso dalla `m28`, DD-043: i job non esistono più)** — 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 dal registro
|
||||
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
|
||||
@@ -249,6 +323,9 @@ invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stess
|
||||
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
|
||||
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
|
||||
|
||||
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
|
||||
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
|
||||
|
||||
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
|
||||
@@ -263,7 +340,7 @@ ritrova per sempre nell'elenco, solo segnata come letta.
|
||||
|
||||
## Push dei promemoria (M24)
|
||||
|
||||
I promemoria a 24 e 3 ore nascono nel database (job `pg_cron`), che non può firmare né cifrare una
|
||||
I promemoria a 24 e 3 ore (rimossi con la `m28`, DD-043; la coda resta valida per i tipi già esistenti) nascevano nel database (job `pg_cron`), che non può firmare né cifrare una
|
||||
push Web: la manda il worker `mailer/`, lo stesso che invia le email ([WORKER_EMAIL.md](../WORKER_EMAIL.md),
|
||||
DD-038).
|
||||
|
||||
@@ -285,6 +362,11 @@ DD-038).
|
||||
La push dei promemoria dipende solo dai dispositivi iscritti, non dall'interruttore «Email». Se il
|
||||
worker non ha le chiavi VAPID parte lo stesso e manda solo le email, segnalandolo nel log.
|
||||
|
||||
Con DD-040 il trigger accoda in `notifiche_push_coda` anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||
`sollecita_presenze_24h`, `_12h` e `_6h`, perché nascono nel database e nessuna route manda la
|
||||
push (quelle dei pulsanti, `turno_palloni` e `sollecita_presenze`, restano fuori dalla coda): le regole di invio, backoff e scarto sono quelle qui sopra. Un sollecito in coda da oltre 3 ore si scarta come un
|
||||
promemoria.
|
||||
|
||||
## Canale email (M22)
|
||||
|
||||
Ogni riga di `notifiche_utente` viene inviata anche per email da un worker esterno, con
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`),
|
||||
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
|
||||
|
||||
---
|
||||
|
||||
@@ -37,22 +38,27 @@ corrente dinamico (vedi sotto).
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
|
||||
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||
primo obiettivo non completato).
|
||||
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
|
||||
badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
|
||||
dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
|
||||
`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
|
||||
`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
|
||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||
@@ -65,7 +71,22 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d
|
||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||
seguono di conseguenza.
|
||||
seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
|
||||
ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono
|
||||
presenze perse.
|
||||
|
||||
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
|
||||
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
|
||||
`adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a
|
||||
0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il
|
||||
passaggio a completato compare al refresh successivo.
|
||||
|
||||
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
|
||||
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
|
||||
conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i
|
||||
convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione
|
||||
`percentualePresenze()`. Non c'è un confine di stagione esplicito: conta ciò
|
||||
che c'è in `eventi_app`.
|
||||
|
||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||
@@ -144,21 +165,29 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti
|
||||
contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa,
|
||||
confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia
|
||||
del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test
|
||||
diventa rosso.
|
||||
- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi,
|
||||
«Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato
|
||||
in corso/completato.
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||
(evento dentro/fuori mese), scadenza dinamica.
|
||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
|
||||
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
|
||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||
|
||||
+13
-5
@@ -10,8 +10,8 @@
|
||||
## Obiettivo
|
||||
|
||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
||||
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
|
||||
notifiche e senza email 3 ore prima dell'evento (DD-040, DD-042). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||
compleanni non c'entrano: non richiedono palloni.
|
||||
|
||||
---
|
||||
@@ -26,7 +26,7 @@ compaiono.
|
||||
|
||||
## Implementazione
|
||||
|
||||
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
|
||||
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
|
||||
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
|
||||
@@ -55,6 +55,13 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
|
||||
|
||||
## Route API pubblica `/api/public/promemoria-palloni`
|
||||
|
||||
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
|
||||
> `pg_cron` ogni 15 minuti manda l'avviso **una sola volta, 3 ore prima** dell'evento (DD-042, migration `m27`), solo se per l'evento c'è un
|
||||
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
|
||||
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
|
||||
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
|
||||
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
|
||||
|
||||
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
|
||||
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
|
||||
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
|
||||
@@ -69,8 +76,9 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
|
||||
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
|
||||
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
|
||||
@@ -68,14 +68,22 @@ compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||
↓
|
||||
src/routes/api/public/sollecita-presenze.ts
|
||||
├─ legge l'evento (eventi_app) e le risposte già date
|
||||
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
|
||||
├─ destinatariSollecito() → src/lib/presenze.ts
|
||||
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
|
||||
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
|
||||
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
|
||||
├─ per ciascuno invia una push col testo cifrato nel payload
|
||||
│ (src/lib/webpush.server.ts)
|
||||
└─ elimina le iscrizioni push scadute (404/410)
|
||||
```
|
||||
|
||||
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
|
||||
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
|
||||
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **una sola volta, 24 ore prima** dell'evento (DD-045, migration `m30`) a chi in
|
||||
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
|
||||
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
|
||||
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
|
||||
|
||||
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
|
||||
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
|
||||
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
@@ -86,9 +94,9 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
|
||||
|
||||
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
|
||||
server, la UI risponde subito.
|
||||
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
|
||||
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
|
||||
(DD-024).
|
||||
- Il sollecito ha due vie (DD-040): un sollecito **automatico** (24 ore prima, job `pg_cron`
|
||||
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
|
||||
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
|
||||
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
|
||||
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
|
||||
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
|
||||
|
||||
@@ -44,7 +44,7 @@ Può:
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
|
||||
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
||||
Non può caricare o sostituire i file altrui: documento e certificato restano
|
||||
responsabilità del giocatore che li fornisce.
|
||||
|
||||
## Flusso utente
|
||||
@@ -73,7 +73,6 @@ Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_),
|
||||
- Dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
|
||||
Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
|
||||
@@ -82,7 +81,7 @@ sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035).
|
||||
**Stato:** implementato (DD-035, soglia del giocatore DD-041).
|
||||
|
||||
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
|
||||
@@ -90,13 +89,13 @@ 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) | — |
|
||||
| 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 | **entrambi**: lo staff (senza il suo nome) e il proprio personale, a 30 giorni | personale: apre `/profilo?tab=documenti` |
|
||||
| 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
|
||||
@@ -109,21 +108,32 @@ 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`.
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
|
||||
soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
|
||||
staff.
|
||||
|
||||
| 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 |
|
||||
| Chi guarda | Soglia |
|
||||
| ---------- | ------------------------------------------ |
|
||||
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
|
||||
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| --------------------- | ----------- | ------------------------------ |
|
||||
| `giorni > soglia` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | rosso (`destructive`) (DD-046) |
|
||||
|
||||
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
|
||||
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
|
||||
|
||||
- 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.
|
||||
- Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello
|
||||
scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti),
|
||||
l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio
|
||||
nome. Non ha soglie: resta finché il file non è caricato.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
@@ -136,7 +146,7 @@ solo proprio a un giocatore.
|
||||
| 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
|
||||
Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
@@ -182,14 +192,16 @@ 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 />` |
|
||||
| Pezzo | Ruolo |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
|
||||
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) |
|
||||
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
|
||||
| `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.
|
||||
@@ -197,7 +209,7 @@ la Home non deve rompersi per un dato accessorio.
|
||||
`/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),
|
||||
- Test in `test/unit/profili-core.test.ts`: avvisi per utente (admin giocatore, admin puro, giocatore semplice), soglia dello staff (8 giorni no, 7 sì, 0 sì, −1 scaduto) e del giocatore (31 no, 30 sì),
|
||||
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.
|
||||
@@ -254,17 +266,16 @@ 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
|
||||
Quando mancano 30 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home; compare in quello degli admin da 7 giorni prima (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
|
||||
Upload di una fotografia formato tessera.
|
||||
|
||||
Utilizzata dagli amministratori per il tesseramento CSI.
|
||||
**Rimossa** (DD-044, migration `m29`): il profilo non chiede più la foto tessera e la colonna
|
||||
`foto_path` non esiste più. I file già caricati sono stati cancellati dal bucket.
|
||||
|
||||
### Statistiche
|
||||
|
||||
@@ -317,17 +328,13 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
- Documento di identità
|
||||
- Foto tessera
|
||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Scarica foto tessera
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||
@@ -353,12 +360,9 @@ Campi esportati.
|
||||
|
||||
## Tracciamento tesseramento
|
||||
|
||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||
complessivo della squadra.
|
||||
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
|
||||
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
|
||||
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
|
||||
|
||||
## Completamento profilo
|
||||
|
||||
@@ -366,10 +370,9 @@ Ogni sezione contribuisce alla percentuale di completamento.
|
||||
|
||||
| Sezione | Peso |
|
||||
| --------------------- | ---- |
|
||||
| Dati personali | 30% |
|
||||
| Documento di identità | 30% |
|
||||
| Certificato medico | 30% |
|
||||
| Foto tessera | 10% |
|
||||
| Dati personali | 34% |
|
||||
| Documento di identità | 33% |
|
||||
| Certificato medico | 33% |
|
||||
|
||||
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
|
||||
@@ -391,7 +394,6 @@ nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
- Gestione dati personali
|
||||
- Documento di identità
|
||||
- Certificato medico
|
||||
- Foto tessera
|
||||
- Dashboard amministratore
|
||||
- Esportazione CSV CSI
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Modulo — Serie di presenze
|
||||
|
||||
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||
**Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
|
||||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||||
`src/components/crapp/SerieCard.tsx`
|
||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
||||
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
|
||||
`test/integration/serie-allenamenti-badge.test.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -19,15 +19,18 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
|
||||
|
||||
---
|
||||
|
||||
## Le tre serie in sintesi
|
||||
## Le due serie in sintesi
|
||||
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | -------------------------------------------- | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
|
||||
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
|
||||
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
|
||||
restano nel database, ma oggi nessuna statistica li usa.
|
||||
|
||||
Esiste un terzo contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||
`src/routes/profilo.tsx`.
|
||||
@@ -67,10 +70,10 @@ il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto
|
||||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | ----------------------- |
|
||||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | -------------------------------------- |
|
||||
| `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||
@@ -147,26 +150,6 @@ Conseguenze da conoscere prima di cambiare qualcosa:
|
||||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||||
|
||||
### `serieConferme()` — conferme entro 24 ore
|
||||
|
||||
```ts
|
||||
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||||
```
|
||||
|
||||
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||||
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||||
|
||||
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||||
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||||
sulla reattività, non sulla presenza.
|
||||
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||||
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||||
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||||
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||||
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||||
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||||
l'istante giusto da confrontare.
|
||||
|
||||
### Lettura e cache
|
||||
|
||||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||||
@@ -263,8 +246,6 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
||||
| Dove | Cosa | Soglie |
|
||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||||
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||||
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||||
|
||||
---
|
||||
@@ -320,8 +301,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
||||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
|
||||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||||
|
||||
+10
-11
@@ -12,7 +12,7 @@
|
||||
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
|
||||
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
|
||||
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
|
||||
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
|
||||
Prima di [DD-015](../decisions/DD-015.md)
|
||||
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
|
||||
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
@@ -48,24 +48,23 @@ gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
||||
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
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche»). 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)):
|
||||
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.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`) |
|
||||
| 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](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
|
||||
([DD-018](../decisions/DD-018.md)).
|
||||
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
|
||||
dell'admin.
|
||||
|
||||
|
||||
Generated
+249
-2046
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
@@ -20,10 +20,10 @@
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"@tanstack/react-router": "^1.170.41",
|
||||
"@tanstack/react-start": "^1.168.60",
|
||||
"@tanstack/router-plugin": "^1.168.42",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
|
||||
* sua e un titolo, così si distingue anche senza vedere i colori.
|
||||
*/
|
||||
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
|
||||
|
||||
const toni: Record<TonoAvviso, string> = {
|
||||
critico: "bg-destructive text-destructive-foreground",
|
||||
attenzione: "bg-warning text-warning-foreground",
|
||||
scaduto: "bg-primary text-primary-foreground",
|
||||
azione: "bg-accent-grad text-accent-foreground",
|
||||
};
|
||||
|
||||
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
|
||||
export type RigaAvviso = { chi: string; cosa: string };
|
||||
|
||||
type ProprietaCorpo = {
|
||||
tono: TonoAvviso;
|
||||
icona: LucideIcon;
|
||||
titolo: string;
|
||||
/** Frasi di spiegazione, una per paragrafo. */
|
||||
testi?: string[];
|
||||
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
|
||||
elenco?: RigaAvviso[];
|
||||
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
|
||||
cliccabile?: boolean;
|
||||
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
|
||||
etichetta?: string;
|
||||
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
|
||||
riquadri?: boolean;
|
||||
};
|
||||
|
||||
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
|
||||
export function CorpoAvviso({
|
||||
tono,
|
||||
icona: Icona,
|
||||
titolo,
|
||||
testi = [],
|
||||
elenco = [],
|
||||
cliccabile = false,
|
||||
etichetta,
|
||||
riquadri = false,
|
||||
}: ProprietaCorpo) {
|
||||
return (
|
||||
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
|
||||
>
|
||||
<Icona className="h-6 w-6" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
|
||||
{titolo}
|
||||
</p>
|
||||
{etichetta ? (
|
||||
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
|
||||
{etichetta}
|
||||
</span>
|
||||
) : null}
|
||||
{elenco.length > 1 ? (
|
||||
<span
|
||||
aria-label={`${elenco.length} persone`}
|
||||
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
|
||||
>
|
||||
{elenco.length}
|
||||
</span>
|
||||
) : null}
|
||||
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
|
||||
</div>
|
||||
|
||||
{testi.map((t) => (
|
||||
<p
|
||||
key={t}
|
||||
className={cn(
|
||||
"mt-3 text-[15px] leading-snug",
|
||||
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{elenco.length > 0 ? (
|
||||
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
|
||||
{elenco.map((r) => (
|
||||
<li
|
||||
key={`${r.chi}-${r.cosa}`}
|
||||
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
|
||||
>
|
||||
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
|
||||
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
|
||||
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
|
||||
*/
|
||||
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
|
||||
const card = <CorpoAvviso {...corpo} />;
|
||||
return (
|
||||
<Reveal indice={indice} className="mx-5 mt-4">
|
||||
{corpo.cliccabile ? (
|
||||
<Link
|
||||
to="/profilo"
|
||||
search={{ tab: "documenti" }}
|
||||
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
) : (
|
||||
<div role="status">{card}</div>
|
||||
)}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { CalendarClock, FileClock, FileX } from "lucide-react";
|
||||
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificati,
|
||||
avvisiCertificatiUtente,
|
||||
formatDataBreve,
|
||||
testoScadenza,
|
||||
type AvvisoCertificato,
|
||||
@@ -12,44 +12,10 @@ 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. */
|
||||
/**
|
||||
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
|
||||
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
|
||||
*/
|
||||
export function AvvisoCertificati() {
|
||||
const admin = useIsAdmin();
|
||||
const ruoliPronti = useRuoliPronti();
|
||||
@@ -61,53 +27,74 @@ export function AvvisoCertificati() {
|
||||
|
||||
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"
|
||||
// Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
|
||||
const { personale, personaleMancante, staff } = avvisiCertificatiUtente(rosa, profili, oggi, {
|
||||
admin,
|
||||
base,
|
||||
});
|
||||
const cardPersonale = personaleMancante ? (
|
||||
<Avviso
|
||||
tono="critico"
|
||||
icona={FileX}
|
||||
titolo="Certificato mancante"
|
||||
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)}`]}
|
||||
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
||||
/>
|
||||
) : personale ? (
|
||||
personale.giorni < 0 ? (
|
||||
<Avviso
|
||||
tono="scaduto"
|
||||
icona={FileClock}
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
testi={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Avviso
|
||||
tono="attenzione"
|
||||
icona={CalendarClock}
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
|
||||
if (!staff) return cardPersonale;
|
||||
const { scaduti, inScadenza, mancanti } = staff;
|
||||
return (
|
||||
<>
|
||||
{cardPersonale}
|
||||
{mancanti.length > 0 ? (
|
||||
<Avviso
|
||||
tono="critico"
|
||||
icona={FileX}
|
||||
titolo="Certificati mancanti"
|
||||
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
|
||||
/>
|
||||
) : null}
|
||||
{scaduti.length > 0 ? (
|
||||
<Avviso
|
||||
tono="scaduto"
|
||||
icona={FileClock}
|
||||
titolo="Certificati scaduti"
|
||||
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Avviso
|
||||
tono="attenzione"
|
||||
icona={CalendarClock}
|
||||
titolo="Certificati in scadenza"
|
||||
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
|
||||
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
|
||||
}
|
||||
@@ -33,7 +33,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||
{def.descrizione}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
@@ -42,13 +42,15 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
{stato ? (
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Stato attuale
|
||||
</p>
|
||||
{grado ? (
|
||||
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
|
||||
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
|
||||
{meta?.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold uppercase text-muted-foreground">
|
||||
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
||||
In progresso
|
||||
</span>
|
||||
)}
|
||||
@@ -59,19 +61,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
{stato.prossimaSoglia ? (
|
||||
<>
|
||||
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
|
||||
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
|
||||
<p className="mt-2 text-[13px] text-success">Hai raggiunto il livello massimo.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Soglie
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -88,7 +90,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-bold uppercase",
|
||||
"text-[13px] font-bold uppercase",
|
||||
raggiunto ? gm.text : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -102,7 +104,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
>
|
||||
{def.soglie[g]}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{def.unita}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -110,7 +112,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
</div>
|
||||
|
||||
{def.celebrazione ? (
|
||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
|
||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
||||
“{def.celebrazione}”
|
||||
</p>
|
||||
) : null}
|
||||
@@ -142,7 +144,7 @@ export function BadgeDrawer({
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Indietro
|
||||
</button>
|
||||
|
||||
@@ -51,11 +51,11 @@ export function CelebrazioneBadge() {
|
||||
{notifica.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
|
||||
</p>
|
||||
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{notifica.testo}</p>
|
||||
<p className="mt-1.5 text-[15px] text-muted-foreground">{notifica.testo}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -69,7 +69,7 @@ export function CelebrazioneBadge() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={chiudi}
|
||||
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
|
||||
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-[15px] font-bold uppercase tracking-wide text-accent-foreground"
|
||||
>
|
||||
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
|
||||
</button>
|
||||
|
||||
@@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
|
||||
{meta ? (
|
||||
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||
<span className={cn("text-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
</p>
|
||||
{b.prossimaSoglia ? (
|
||||
<>
|
||||
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
|
||||
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
|
||||
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
)}
|
||||
</Reveal>
|
||||
);
|
||||
@@ -90,13 +90,15 @@ function SocialDrawer({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||
{cat.descrizione}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Vinto
|
||||
</p>
|
||||
<p className="mt-1 font-display text-3xl leading-none">
|
||||
{conteggio}{" "}
|
||||
<span className="text-lg text-muted-foreground">
|
||||
@@ -109,7 +111,7 @@ function SocialDrawer({
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Indietro
|
||||
</button>
|
||||
@@ -139,7 +141,7 @@ export function CollezioneBadge({
|
||||
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
Collezione badge
|
||||
</p>
|
||||
<p className="font-display text-4xl leading-none">
|
||||
@@ -147,7 +149,7 @@ export function CollezioneBadge({
|
||||
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
<p className="text-[13px] text-primary-foreground/70">
|
||||
<Numero valore={pct} suffisso="%" /> completata
|
||||
</p>
|
||||
</div>
|
||||
@@ -156,12 +158,12 @@ export function CollezioneBadge({
|
||||
|
||||
{vicino ? (
|
||||
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||
Prossimo traguardo
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
|
||||
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
|
||||
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -169,7 +171,7 @@ export function CollezioneBadge({
|
||||
|
||||
{c.sbloccati.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Sbloccati ({c.sbloccati.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -184,7 +186,7 @@ export function CollezioneBadge({
|
||||
|
||||
{c.inProgresso.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
In progresso ({c.inProgresso.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -199,7 +201,7 @@ export function CollezioneBadge({
|
||||
|
||||
{socialVinti.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Votati dai compagni
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -207,8 +209,8 @@ export function CollezioneBadge({
|
||||
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
|
||||
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-accent/25">
|
||||
<p className="text-lg leading-none">{cat.emoji}</p>
|
||||
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[15px] font-bold leading-tight">{cat.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -219,7 +221,7 @@ export function CollezioneBadge({
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge segreti
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -229,8 +231,8 @@ export function CollezioneBadge({
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-oro/40">
|
||||
<Icon className="h-6 w-6 text-oro" />
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -243,8 +245,10 @@ export function CollezioneBadge({
|
||||
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
|
||||
>
|
||||
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
|
||||
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight text-muted-foreground">
|
||||
???
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MetaPartitaCsi({
|
||||
.join(" · ");
|
||||
if (!voci && !link) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
|
||||
{voci ? <span>{voci}</span> : null}
|
||||
{link ? (
|
||||
<a
|
||||
@@ -68,17 +68,17 @@ export function MetaPartitaCsi({
|
||||
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{squadra.squadra}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{squadra.titolari.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
||||
<div key={i} className="flex items-center gap-2 text-[15px]">
|
||||
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
|
||||
{g.numero}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
||||
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{squadra.panchina.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div key={i} className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
</>
|
||||
) : null}
|
||||
{squadra.staff.length > 0 ? (
|
||||
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
||||
<div className="mt-3 space-y-0.5 text-[13px] text-muted-foreground">
|
||||
{squadra.staff.map((s, i) => (
|
||||
<div key={i}>
|
||||
{s.nome} · {s.ruolo}
|
||||
@@ -121,7 +121,7 @@ function BarraPrecedenti({
|
||||
}) {
|
||||
const totale = noi + avversario || 1;
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<div className="text-[13px]">
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="font-bold text-foreground">{noi}</span>
|
||||
<span>{label}</span>
|
||||
@@ -139,7 +139,7 @@ const NOME_NOI = "CRAP Volley";
|
||||
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
|
||||
function TestataSquadre({ avversario }: { avversario: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<div className="flex items-center justify-between text-[13px] font-bold">
|
||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ function BarraProbabilita({
|
||||
const favoritaNoi = noi >= avversarioPct;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
<p className="mb-2 text-[13px] text-muted-foreground">
|
||||
Probabilità di vittoria (calcolo CSI):{" "}
|
||||
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||
@@ -171,13 +171,13 @@ function BarraProbabilita({
|
||||
</div>
|
||||
<div className="flex h-7 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
||||
className="flex items-center justify-center bg-success text-[13px] font-bold text-success-foreground"
|
||||
style={{ width: `${noi}%` }}
|
||||
>
|
||||
{noi.toFixed(0)}%
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
||||
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
|
||||
style={{ width: `${avversarioPct}%` }}
|
||||
>
|
||||
{avversarioPct.toFixed(0)}%
|
||||
@@ -197,7 +197,7 @@ function BloccoPrecedenti({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TestataSquadre avversario={avversario} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{precedenti.totale > 0
|
||||
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||
@@ -248,20 +248,22 @@ export function DettaglioCsiEsteso({
|
||||
const { data, isLoading } = useCsiPartita(matchId);
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||
return (
|
||||
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
|
||||
);
|
||||
}
|
||||
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.giornata || data.nota ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{data.formazioni ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -272,7 +274,7 @@ export function DettaglioCsiEsteso({
|
||||
) : null}
|
||||
{data.precedenti ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||
</div>
|
||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
|
||||
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 { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
|
||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
@@ -75,7 +75,8 @@ export function EventoCard({
|
||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter(inRosa);
|
||||
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
|
||||
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -101,10 +102,10 @@ export function EventoCard({
|
||||
<Cake className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
|
||||
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
|
||||
<h3 className="truncate text-[15px] font-bold leading-tight">{evento.titolo}</h3>
|
||||
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -139,13 +140,13 @@ export function EventoCard({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
"inline-block rounded-full px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
||||
tipo.className,
|
||||
)}
|
||||
>
|
||||
{tipo.label}
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
|
||||
</span>
|
||||
@@ -153,10 +154,10 @@ export function EventoCard({
|
||||
|
||||
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
||||
{sottotitoloPartita ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">{sottotitoloPartita}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
|
||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{evento.ora}</span>
|
||||
@@ -174,12 +175,12 @@ export function EventoCard({
|
||||
</div>
|
||||
|
||||
{noteCard ? (
|
||||
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
|
||||
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
|
||||
) : null}
|
||||
|
||||
{metaStato ? (
|
||||
<p
|
||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
|
||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span aria-hidden>{metaStato.emoji}</span>
|
||||
@@ -187,7 +188,7 @@ export function EventoCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{io ? (
|
||||
{io && eConvocato(evento, io.id) ? (
|
||||
<div
|
||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||
role="group"
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{voci.map((v) => (
|
||||
<li key={v} className="flex gap-2 text-[15px] leading-snug">
|
||||
<span aria-hidden className="shrink-0 font-bold text-accent">
|
||||
{segno}
|
||||
</span>
|
||||
<span>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Blocco({ titolo, testo }: { titolo: string; testo: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo}
|
||||
</p>
|
||||
<p className="text-[15px] leading-relaxed">{testo}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
|
||||
export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
|
||||
const pct = progressoObiettivo(o);
|
||||
const fatto = pct >= 100;
|
||||
const d = o.dettaglio;
|
||||
return (
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 pt-0">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-secondary text-2xl ring-1 ring-border">
|
||||
{o.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="text-xl font-bold leading-tight">{o.titolo}</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||
{o.descrizione}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Stato attuale
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[13px] font-bold uppercase",
|
||||
fatto ? "text-success" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fatto ? "Completato" : "In corso"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-display text-3xl leading-none">
|
||||
{o.valore} <span className="text-lg text-muted-foreground">su {o.target}</span>{" "}
|
||||
<span className="text-lg text-muted-foreground">{o.unita}</span>
|
||||
</p>
|
||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
{pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||
</div>
|
||||
|
||||
<Blocco titolo="Come si calcola" testo={d.comeSiCalcola} />
|
||||
<Elenco titolo="Cosa conta" voci={d.conta} segno="+" />
|
||||
{d.perse ? <Elenco titolo="Presenza persa" voci={d.perse} segno="−" /> : null}
|
||||
<Elenco
|
||||
titolo={d.perse ? "Non entra nel calcolo" : "Cosa non conta"}
|
||||
voci={d.nonConta}
|
||||
segno={d.perse ? "·" : "−"}
|
||||
/>
|
||||
<Blocco titolo="Quando vale" testo={d.periodo} />
|
||||
<Blocco titolo="Da dove arrivano i dati" testo={d.fonte} />
|
||||
<Blocco titolo="Un esempio" testo={d.esempio} />
|
||||
|
||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
||||
{o.impatto}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
|
||||
export function ObiettivoDrawer({
|
||||
obiettivo,
|
||||
children,
|
||||
}: {
|
||||
obiettivo: ObiettivoSquadra;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>{obiettivo.titolo}</DrawerTitle>
|
||||
<DrawerDescription>{obiettivo.descrizione}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<SchedaObiettivo o={obiettivo} />
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Indietro
|
||||
</button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -45,19 +45,19 @@ export function Pagelle({
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
||||
</p>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
|
||||
</p>
|
||||
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
|
||||
<p aria-busy="true" className="mt-3 text-[13px] text-muted-foreground">
|
||||
Carico le pagelle…
|
||||
</p>
|
||||
) : (
|
||||
@@ -70,15 +70,17 @@ export function Pagelle({
|
||||
return (
|
||||
<div key={g.id} className="rounded-2xl bg-secondary/60 p-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
{g.nome}
|
||||
</span>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
|
||||
</span>
|
||||
</span>
|
||||
{sonoIo ? (
|
||||
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
||||
<span className="shrink-0 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
Sei tu
|
||||
</span>
|
||||
) : chiuse ? (
|
||||
@@ -89,7 +91,7 @@ export function Pagelle({
|
||||
disabled={!io || vota.isPending}
|
||||
onClick={() => setApertoPer(aperto ? null : g.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
|
||||
"shrink-0 rounded-full px-3 py-1 text-[13px] font-bold uppercase transition-colors disabled:opacity-50",
|
||||
mio !== undefined
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
@@ -107,7 +109,7 @@ export function Pagelle({
|
||||
type="button"
|
||||
onClick={() => invia(g.id, v)}
|
||||
className={cn(
|
||||
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
|
||||
"rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90",
|
||||
mio === v
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
|
||||
@@ -27,7 +27,7 @@ const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
|
||||
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
||||
<h3 className="font-display text-[15px] uppercase tracking-wide">{titolo}</h3>
|
||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -67,7 +67,7 @@ function CampoFile({
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="flex min-w-0 items-center gap-2 text-[15px]">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
||||
@@ -93,7 +93,7 @@ function CampoFile({
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={inCorso}
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-[13px] font-bold text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
||||
</button>
|
||||
@@ -120,14 +120,12 @@ export function CampiProfilo({
|
||||
sezioni,
|
||||
fileDocumento,
|
||||
fileCertificato,
|
||||
fileFoto,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
sezioni: Record<Sezione, boolean>;
|
||||
fileDocumento?: React.ReactNode;
|
||||
fileCertificato?: React.ReactNode;
|
||||
fileFoto?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
@@ -241,14 +239,13 @@ export function CampiProfilo({
|
||||
/>
|
||||
</Campo>
|
||||
{fileCertificato}
|
||||
{fileFoto}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* indirizzo, documento e certificato. Condivisi tra Profilo e dashboard admin.
|
||||
*/
|
||||
export function CampiAllenatore({
|
||||
corrente,
|
||||
@@ -339,7 +336,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -369,7 +366,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
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"
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
@@ -433,7 +430,7 @@ export function ProfiloAmministrativo({
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -469,25 +466,13 @@ export function ProfiloAmministrativo({
|
||||
onCaricato={caricato("certificatoPath")}
|
||||
/>
|
||||
}
|
||||
fileFoto={
|
||||
<>
|
||||
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
|
||||
<CampoFile
|
||||
label="Foto tessera"
|
||||
path={corrente.fotoPath}
|
||||
sezione="foto"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("fotoPath")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={salvaBozza}
|
||||
disabled={!sporco || salva.isPending}
|
||||
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"
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
@@ -501,7 +486,9 @@ export function ProfiloAmministrativo({
|
||||
<SezioneTendina
|
||||
titolo="Dati per il tesseramento"
|
||||
indice={indice}
|
||||
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
||||
azione={
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
}
|
||||
>
|
||||
{corpo}
|
||||
</SezioneTendina>
|
||||
@@ -536,10 +523,10 @@ export function CompletaProfilo({
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-display text-[15px] uppercase tracking-wide">
|
||||
<span className="font-display text-[16px] uppercase tracking-wide">
|
||||
Completa il tuo profilo
|
||||
</span>
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
@@ -547,10 +534,10 @@ export function CompletaProfilo({
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-[14px] text-muted-foreground">
|
||||
{allenatore
|
||||
? "Data e luogo di nascita, telefono ed email."
|
||||
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
|
||||
: "Documento e certificato medico servono per il tesseramento CSI."}
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Volleyball } from "lucide-react";
|
||||
import { Avviso } from "@/components/crapp/Avviso";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
@@ -17,6 +18,8 @@ export function PromemoriaPalloni() {
|
||||
|
||||
const oggi = oggiISO();
|
||||
const messaggi: string[] = [];
|
||||
// «Oggi» solo se c'è davvero qualcosa da fare oggi; altrimenti è un semplice promemoria.
|
||||
let oggiTocca = true;
|
||||
|
||||
for (const evento of lista) {
|
||||
if (evento.data !== oggi) continue;
|
||||
@@ -36,6 +39,7 @@ export function PromemoriaPalloni() {
|
||||
}
|
||||
|
||||
if (messaggi.length === 0) {
|
||||
oggiTocca = false;
|
||||
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
|
||||
if (!prossimo) return null;
|
||||
messaggi.push(
|
||||
@@ -44,15 +48,13 @@ export function PromemoriaPalloni() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> Turno palloni
|
||||
</p>
|
||||
{messaggi.map((m) => (
|
||||
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{m}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Avviso
|
||||
tono="azione"
|
||||
icona={Volleyball}
|
||||
titolo="Turno palloni"
|
||||
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
|
||||
testi={messaggi}
|
||||
riquadri
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import type { EventoTipo } from "@/lib/eventi";
|
||||
import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
@@ -20,9 +20,12 @@ export function RosaPresenze({
|
||||
eventoId,
|
||||
data,
|
||||
tipo = "allenamento",
|
||||
convocati = [],
|
||||
}: {
|
||||
eventoId: string;
|
||||
data: string;
|
||||
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
|
||||
convocati?: string[];
|
||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||
tipo?: EventoTipo;
|
||||
}) {
|
||||
@@ -34,7 +37,8 @@ export function RosaPresenze({
|
||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const anagrafica = useAnagraficaRosa();
|
||||
const rosa = rosaConvocata(anagrafica, convocati);
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
|
||||
@@ -70,7 +74,7 @@ export function RosaPresenze({
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-bold">
|
||||
<p className="text-[15px] font-bold">
|
||||
Hanno risposto {risposteN}/{rosa.length}
|
||||
</p>
|
||||
<span className="font-display text-xl leading-none">{perc}%</span>
|
||||
@@ -84,7 +88,7 @@ export function RosaPresenze({
|
||||
<span
|
||||
key={s}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs font-bold",
|
||||
"rounded-full px-2.5 py-1 text-[13px] font-bold",
|
||||
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -92,14 +96,14 @@ export function RosaPresenze({
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
|
||||
❔ {mancanti.length} da rispondere
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{io ? (
|
||||
{io && rosa.some((g) => g.id === io.id) ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
La tua risposta
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -114,7 +118,7 @@ export function RosaPresenze({
|
||||
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||
attivo
|
||||
? cn(statoMeta[s].className, "border-transparent shadow-card")
|
||||
: "bg-background text-muted-foreground",
|
||||
@@ -133,7 +137,7 @@ export function RosaPresenze({
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
disabled={sollecito || daSollecitare === 0 || passato}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{sollecito ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -146,7 +150,7 @@ export function RosaPresenze({
|
||||
</Card>
|
||||
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
|
||||
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
|
||||
Carico le risposte…
|
||||
</p>
|
||||
) : null}
|
||||
@@ -194,16 +198,16 @@ function Gruppo({
|
||||
attenzione && "border border-dashed border-warning",
|
||||
)}
|
||||
>
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo} · {n}
|
||||
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{lista.map((g) => (
|
||||
<div key={g.id} className="flex items-center gap-3">
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{g.nome}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{g.ruolo}</span>
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
|
||||
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ScoutEntry({
|
||||
{occupato ? <Lock className="h-5 w-5 shrink-0" /> : <Radio className="h-5 w-5 shrink-0" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-lg uppercase leading-none">{titolo}</span>
|
||||
<span className="block text-xs opacity-80">{sottotitolo}</span>
|
||||
<span className="block text-[13px] opacity-80">{sottotitolo}</span>
|
||||
</span>
|
||||
{disponibile ? <ChevronRight className="h-5 w-5 shrink-0" /> : null}
|
||||
</>
|
||||
|
||||
@@ -28,8 +28,8 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.def.descrizione}</p>
|
||||
<p className="text-[15px] font-bold leading-tight">{s.def.label}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame
|
||||
@@ -39,7 +39,7 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={s.progresso} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""}
|
||||
{s.messaggio}
|
||||
</p>
|
||||
@@ -61,17 +61,17 @@ export function SerieHome({ g }: { g: Giocatore }) {
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-tight">
|
||||
<p className="text-[15px] font-bold leading-tight">
|
||||
Serie {top.def.label.toLowerCase()}: {top.valore}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{top.messaggio}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{serieGiocatore(g).map((s) => (
|
||||
<div key={s.def.tipo} className="rounded-2xl bg-secondary p-2 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.valore}</p>
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
{s.def.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -81,10 +81,10 @@ export function SondaggioCacche({
|
||||
if (!sondaggioAperto(dataEvento, oraEvento)) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
💩 Sondaggio pre-partita
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
{sondaggioTerminato(dataEvento, oraEvento)
|
||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||
@@ -96,14 +96,14 @@ export function SondaggioCacche({
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
💩 Sondaggio pre-partita
|
||||
</p>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
{dellaPartita.length} risposte
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
|
||||
</p>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function SondaggioCacche({
|
||||
disabled={!io || salva.isPending}
|
||||
onClick={() => rispondi(n)}
|
||||
className={cn(
|
||||
"min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||
"min-w-11 rounded-xl px-3 py-2 text-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||
mia?.quantita === n
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -126,7 +126,7 @@ export function SondaggioCacche({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
@@ -145,7 +145,7 @@ export function SondaggioCacche({
|
||||
type="button"
|
||||
onClick={avvisaTutti}
|
||||
disabled={avviso}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
|
||||
Avvisa tutti del sondaggio
|
||||
|
||||
@@ -74,16 +74,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
<CircleDot className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="block text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Palloni
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm font-bold leading-tight">
|
||||
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
|
||||
{isPending || assegna.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
|
||||
{proposto && giocatore ? (
|
||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
proposto
|
||||
</span>
|
||||
) : null}
|
||||
@@ -100,11 +100,11 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
type="button"
|
||||
onClick={() => scegli(g.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[15px] transition-colors",
|
||||
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-[13px]" />
|
||||
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
|
||||
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
|
||||
</button>
|
||||
@@ -117,7 +117,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
type="button"
|
||||
onClick={avvisa}
|
||||
disabled={avviso}
|
||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
|
||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-[13px] font-bold uppercase text-foreground disabled:opacity-50"
|
||||
>
|
||||
{avviso ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -46,10 +46,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,20 +75,20 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{mio ? "Cambia voto" : "Vota"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-xs">
|
||||
<p className="mt-2 text-[13px]">
|
||||
{testa && !pareggio ? (
|
||||
<span className="inline-flex items-center gap-1 font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
|
||||
@@ -100,9 +100,9 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
)}
|
||||
</p>
|
||||
{mio ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||
) : !puoVotare ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Vota chi era presente a questa partita.
|
||||
</p>
|
||||
) : null}
|
||||
@@ -120,7 +120,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
@@ -133,7 +133,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
) : conteggio.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{conteggio.map((c) => (
|
||||
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs font-semibold">
|
||||
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-[13px] font-semibold">
|
||||
{c.nome} · {c.voti}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs font-semibold text-muted-foreground">
|
||||
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-[13px] font-semibold text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
||||
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria
|
||||
@@ -79,14 +79,16 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
{cat.emoji}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
{cat.nome}
|
||||
</span>
|
||||
<span className="block truncate text-[13px] text-muted-foreground">
|
||||
{mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -105,7 +107,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -118,17 +120,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
) : (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
{totale === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Nessun voto ancora: apri e scegli un compagno.
|
||||
</p>
|
||||
) : vincitore ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Parità con {totale} voti: servono altri voti per assegnare il badge.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -107,11 +107,13 @@ function RigaNotifica({
|
||||
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"
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
|
||||
>
|
||||
<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>
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -252,7 +254,7 @@ export function PageHeader({
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
|
||||
{sottotitolo ? (
|
||||
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
|
||||
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{azione ?? <LinkProfilo />}
|
||||
@@ -336,7 +338,7 @@ export function SezioneTendina({
|
||||
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
|
||||
*/
|
||||
export const classiInput =
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
|
||||
|
||||
/**
|
||||
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
|
||||
@@ -347,7 +349,7 @@ export const classiInput =
|
||||
export function Campo({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block">{children}</span>
|
||||
@@ -376,7 +378,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||
meta.className,
|
||||
className,
|
||||
)}
|
||||
@@ -400,10 +402,10 @@ export function StatTile({
|
||||
<p className="font-display text-2xl leading-none">
|
||||
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
|
||||
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const DrawerDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-[15px] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user