135 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 f49351e536 Avviso certificati: mostra anche chi non l'ha caricato (DD-046), versione 1.2.3
Il giocatore senza certificato vede una card rossa cliccabile, l'admin
l'elenco dei mancanti sopra scaduti e in scadenza. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:15:17 +02:00
davideandClaude Sonnet 5.5 008f0d784c Merge develop in main: versione 1.2.2
Avvisi certificati a 30 giorni per il giocatore (DD-041), turno palloni e sollecito presenze una sola volta (DD-042, DD-045), niente promemoria evento (DD-043), niente foto tessera (DD-044). Migration m27-m30 già applicate in produzione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:26 +02:00
davideandClaude Sonnet 5.5 683eb87320 Porta la versione a 1.2.2: avvisi automatici ridotti, niente foto tessera
Changelog della 1.2.2, versione in package.json e stato del progetto: le
migration m27-m30 sono applicate in produzione dal 01/10/2026.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:21 +02:00
davideandClaude Sonnet 5.5 f19f0bbf8d Sollecito presenze: un solo avviso automatico, 24 ore prima (DD-045)
La migration m30 riduce il job automatico alla sola fascia delle 24 ore:
il sollecito arriva una volta a chi non ha risposto o ha risposto «forse»,
anche per un evento creato a meno di 24 ore dall'inizio. Il pulsante
manuale «Sollecita» non cambia. I tipi sollecita_presenze_12h e _6h restano
ammessi per le notifiche già generate. Migration applicata in produzione.

Test di integrazione aggiornati; documentati DD-045, moduli notifiche,
presenze e allenatore, DATABASE, ROADMAP e changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:39:24 +02:00
davideandClaude Sonnet 5.5 de78431090 Toglie la foto tessera dal profilo (DD-044)
Il Profilo non chiede più la foto tessera e la scheda admin non la mostra.
Il completamento diventa dati personali 34%, documento 33%, certificato 33%.
La migration m29 elimina la colonna profili_giocatore.foto_path; le 4 foto
già caricate sono state cancellate dal bucket profili-giocatore. Migration
applicata in produzione.

Test: pesi e sezioni di profili-core, rendering di CampiProfilo, colonna
rimossa sul database locale. Documentati DD-044, modulo profilo-giocatore,
DATABASE, ROADMAP e changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:03:04 +02:00
davideandClaude Sonnet 5.5 e822e4574b Rimuove il promemoria evento a 24 e 3 ore (DD-043)
La migration m28 toglie i due job pg_cron che generavano il promemoria
prima di ogni evento. Funzione, tipi, registro e notifiche già generate
restano. Migration applicata in produzione.

Nuovo test sui job non più pianificati; documentati DD-043, modulo
notifiche, DATABASE, ROADMAP, WORKER_EMAIL e changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:49:30 +02:00
davideandClaude Sonnet 5.5 cdf8b144e2 Turno palloni: un solo avviso, 3 ore prima dell'evento (DD-042)
La migration m27 riduce il job automatico alla sola fascia delle 3 ore:
l'avviso arriva una volta a chi deve portare i palloni e a chi li deve
prendere. I tipi turno_palloni_12h e _6h restano ammessi per le notifiche
già generate e per la revoca. Migration applicata in produzione.

Test di integrazione aggiornati (nessun avviso a 4,5 e 9 ore); documentati
DD-042, moduli, DATABASE, ROADMAP, glossario e changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:33:38 +02:00
davideandClaude Sonnet 5.5 4b5ffc6664 Avviso certificati: il giocatore è avvisato 30 giorni prima (DD-041)
Il giocatore vede l'avviso giallo da 30 giorni prima della scadenza del
certificato medico; per lo staff resta 7 giorni, nero dopo la scadenza per
tutti. L'admin che è anche giocatore vede il proprio avviso personale oltre
a quello dello staff, da cui è escluso il suo nome.

La scelta degli avvisi per utente è in avvisiCertificatiUtente()
(profili-core) con test; documentati DD-041, DD-035, modulo e changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:21:25 +02:00
davideandClaude Sonnet 5.5 8f25143aa5 Riorganizza la documentazione per l'uso con assistenti AI
- Spezza il registro delle decisioni in docs/decisions/DD-XXX.md, con indice
- Ripulisce DATABASE.md dalla storia delle migration e aggiunge la sezione Lettura
- Riduce PROJECT_STATE.md a una pagina; la procedura di allestimento passa in OPERATIONS.md
- Aggiunge OPERATIONS.md, GLOSSARIO.md, trappole note e checklist in ARCHITECTURE.md
- Sintetizza la copertura test di badge.md e documenta docs/archive
- Allinea AGENTS.md, README.md e la regola Cursor (tolto l'obbligo di develop, in contrasto con DD-019)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 08:53:38 +02:00
davideandClaude Sonnet 5.5 c12011d330 Porta la versione a 1.2.1: turno palloni e solleciti presenze automatici
Changelog della 1.2.1, versione in package.json e stato del progetto: la
migration m26 è applicata in produzione e tutte le 37 migration sono allineate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:09:41 +02:00
davideandClaude Sonnet 5.5 7143c3d2eb Corregge la documentazione di palloni e presenze ancora ferma al solo invio manuale
Il sollecito e l'avviso palloni non sono più descritti come solo da pulsante
(DD-040); corretto il controllo di ruolo della route del sollecito; traccia di
DD-040 in DD-025.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:09:41 +02:00
davideandClaude Sonnet 5.5 71b2d97fae Documenta l'implementazione delle notifiche automatiche (DD-040)
DD-040 passa a implementata in locale; catalogo e testi aggiornati; nuova
tabella delle funzioni SQL e dei job in DATABASE.md; note su palloni, presenze,
allenatore e worker; CHANGELOG, ROADMAP, PROJECT_STATE e README dei test.
Corretto lo stato delle migration in produzione (applicate fino a m25).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 9b4e3e4b45 Allinea l'app ai nuovi testi e ai destinatari dei pulsanti (DD-040)
I pulsanti «Avvisa chi è di turno» e «Sollecita» usano gli stessi testi dei job
del database. Il sollecito manuale segue i convocati e dice la risposta attuale
e chi lo ha chiesto. L'ordine degli eventi dei palloni diventa data, ora, id.
Le righe del corpo di una notifica vanno a capo nel centro notifiche.

Test unitari dei testi, dei destinatari e delle date; il test delle route
verifica testi, convocati e assenza di email per i palloni.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 563142a30a Aggiunge la migration m26: notifiche automatiche di palloni e solleciti (DD-040)
Promemoria con «oggi/domani», data, ora e luogo. Turno palloni a 12, 6 e 3 ore
per gli eventi con un incaricato confermato, con revoca una tantum se
l'incaricato cambia. Sollecito presenze a 24, 12 e 6 ore ai destinatari
dell'evento senza risposta o con «forse». Due job pg_cron ogni 15 minuti; push
dei tipi automatici dalla coda; nessuna email per il turno palloni.

Nuovo test di integrazione sui job veri del database; aggiornati i test di
promemoria, code push ed email.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 14:03:50 +02:00
davideandClaude Sonnet 5.5 07624f6b9f Documenta le notifiche automatiche di palloni e presenze e i testi formali (DD-040)
Specifica, senza ancora toccare il codice: promemoria con «oggi/domani», turno
palloni a 12, 6 e 3 ore con revoca dell'incarico e senza email, solleciti
presenze a 24, 12 e 6 ore con gli stessi destinatari del pulsante. I pulsanti
manuali restano. Aggiunge la sezione «Testi delle notifiche» al modulo notifiche.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:06:01 +02:00
davideandClaude Sonnet 5.5 ded71f567b Merge develop in main: versione 1.2.0
Notifiche email via worker Docker su tre canali e promemoria senza ritorni (DD-036..DD-039).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:26:44 +02:00
davideandClaude Sonnet 5.5 b6dee87eb3 Porta la versione a 1.2.0: notifiche su tre canali e promemoria senza ritorni
Changelog dalla 1.1.1: notifiche email con worker Docker e interruttore Email,
push dei promemoria, sondaggio in-app ed email, l'allenatore senza promemoria
degli eventi e la correzione dei promemoria che ricomparivano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 15:07:47 +02:00
davideandClaude Sonnet 5.5 4760e8650c L'allenatore non riceve i promemoria degli eventi; test dei destinatari per ruolo (DD-039)
m25: giocatori_destinatari_evento() include solo i giocatori attivi, quindi
l'allenatore non riceve più i promemoria a 24 e 3 ore, per nessun tipo di
evento (fino alla m24 li riceveva sempre, DD-034). Riceve ancora i messaggi
dello staff.

Test: nuovo destinatari-notifiche.test.ts (14 controlli sulle route vere con un
servizio push finto: promemoria, messaggio dello staff, sondaggio, sollecito,
turno palloni, permessi di allenatore e giocatore) e aggiornato
permessi-allenatore. Documentazione: DD-039, tabella delle notifiche
dell'allenatore, catalogo e tabella dei test che lo verificano.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:48:30 +02:00
davideandClaude Sonnet 5.5 e9013b703b Promemoria senza ritorni e tre canali per ogni notifica (DD-037, DD-038)
m23: un registro (promemoria_eventi_generati) ricorda i promemoria a 24 e 3 ore
già generati, così eliminare la notifica non li fa rigenerare dal cron né
riaccodare la mail a ogni giro.

m24: la push dei promemoria, che nascono nel database, la manda il worker
mailer/ con lo stesso modulo dell'app (coda notifiche_push_coda, esiti, backoff,
iscrizioni scadute eliminate, push scartate oltre 3 ore). Il sondaggio
pre-partita scrive anche notifiche_utente (tipo sondaggio_cacche): compare
in-app e arriva per email. L'immagine del worker si costruisce dalla radice
del repository per includere src/lib/webpush.server.ts.

Test: 9 di integrazione per m23, 17 per la coda push, 6 per la route del
sondaggio, nuovi casi unitari sul worker; prova end-to-end con un servizio
push finto. Documentazione e catalogo delle notifiche aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 14:19:54 +02:00
davideandClaude Sonnet 5.5 58eba642f7 Documenta l'installazione e la gestione del worker email
Nuovo docs/WORKER_EMAIL.md (password per app, installazione, gestione,
diagnosi, prova in locale, spostamento su un altro host); il modulo
notifiche-email.md resta la sola specifica funzionale. Allineati
DD-036, CHANGELOG, PROJECT_STATE, indice e portabilità: migration m22
e worker in produzione dal 29/09/2026.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 12:22:46 +02:00
davideandClaude Sonnet 5.5 969dc1400c Riallinea la formattazione Prettier di due documenti dei moduli
Solo spaziatura delle tabelle in collegamento-csi.md e obiettivi-squadra.md,
nessun cambio di contenuto.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:48:12 +02:00
davideandClaude Sonnet 5.5 b7d3fde225 Implementa le notifiche email via Gmail da un worker Docker (DD-036)
Migration m22: coda notifiche_email_coda, preferenze_utente, trigger che
accoda ogni notifica di notifiche_utente e tre funzioni RPC riservate
alla service role. Worker in mailer/ (bun + nodemailer, solo polling in
uscita) con backoff, tetto giornaliero e healthcheck. Interruttore
«Email» in Profilo → Opzioni, acceso di default. Test unit e di
integrazione; documentazione aggiornata (DD-036 accettata).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:07:24 +02:00
davideandClaude Sonnet 5.5 2a0b611459 Documenta le notifiche email via Gmail da un worker Docker (DD-036)
Specifica del modulo, decisione DD-036 e voce in roadmap. Nessun codice:
ogni notifica di notifiche_utente diventa anche una mail, con coda su
tabella dedicata e preferenza per account in preferenze_utente.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:04:04 +02:00
davideandClaude Sonnet 5.5 cf667453a7 Ignora la cartella ops/ (playbook Ansible locale per il backup cifrato)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:31:23 +02:00
davideandClaude Sonnet 5 39918f2eb7 Scheda di dettaglio per gli eventi extra-campo, senza infortunato tra le risposte
Gli eventi generici (es. cena di squadra) avevano solo il conteggio presenze in
card, senza modo di vedere chi ha risposto. Aggiunta la route /evento/$id sul
modello di allenamento/partita, con RosaPresenze per l'elenco nominativo.

Lo stato "infortunato" non è una risposta pertinente per questi eventi: escluso
da badge, gruppi e bottoni di risposta sia in RosaPresenze che in EventoCard,
tramite la funzione condivisa includeInfortunato() in presenze.ts.

Documentazione dei moduli (calendario, presenze, palloni, badge) allineata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:01:46 +02:00
davideandClaude Sonnet 5 a72eb087d1 Esclude gli eventi generici dal turno palloni
eventiPalloni() ora include solo allenamenti e partite: una cena di
squadra o altro evento generico non genera più proposte automatiche,
promemoria push o conteggi per il turno palloni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:40:16 +02:00
davideandClaude Sonnet 5 34edfc662e Il precedente fix dello swipe non bastava: guarda anche offset.y
dragDirectionLock non ha effetto quando drag è già vincolato a un solo
asse ("x"): il gesto veniva comunque interpretato come swipe orizzontale
sulla sola velocity.x, rumorosa nei flick verticali veloci (bug visto
sia verso Obiettivi che verso Rosa, segno che il rumore cambiava segno).
Ora onDragEnd ignora il gesto se lo spostamento verticale (offset.y) è
maggiore di quello orizzontale (offset.x): uno scroll resta sempre
dominante in verticale, uno swipe intenzionale in orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:23:06 +02:00
davideandClaude Sonnet 5 e0a9d19a02 Porta la versione a 1.1.1: fix swipe tab e keepalive/backup già rilasciati
Aggiunge dragDirectionLock alla tab bar (Squadra > Stats non salta più su
Obiettivi durante uno scroll verticale veloce) e aggiorna il changelog
con quanto già presente su main dalla 1.1.0: keepalive giornaliero,
cifratura di default del backup e la card allenatore uniformata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:19:25 +02:00
davideandClaude Sonnet 5 3c2f9cf876 Uniforma la riga collassata della rosa tra allenatore e giocatori
La card dell'allenatore mostrava subito nome, ruolo e data di nascita,
mentre quella dei giocatori mostra solo nome e ruolo lasciando i
dettagli (nascita, statistiche) dietro l'espansione. Ora anche
l'allenatore è una card espandibile identica, con la data di nascita
visibile solo aprendola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:13:10 +02:00
davideandClaude Sonnet 5 93d70f4879 Usa bun invece di node per lo step di download storage nel backup
download-storage.mjs importa storage-core.ts, che node non può caricare
senza un loader TS; bun lo supporta nativamente ed è già una dipendenza
del progetto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 18:51:23 +02:00
davideandClaude Sonnet 5 20ca6ba5a6 Aggiunge un keepalive giornaliero per evitare la pausa Supabase free tier
Il progetto Supabase free va in pausa dopo 7 giorni senza richieste API.
Un Vercel Cron Job chiama /api/public/keepalive una volta al giorno, che
fa una lettura minima per tenere il progetto attivo senza servizi esterni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:43:28 +02:00
davideandClaude Sonnet 5 342b457ee7 Cifra il backup di default e aggiunge tar.gz + istruzioni decompressione
Prima --encrypt era opt-in; dato che i dump contengono dati sensibili
(hash password, documenti d'identità) la cifratura ora è il comportamento
di default, disattivabile con --no-encrypt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:34:57 +02:00
davide a6defc1a49 Merge branch 'develop' into main: versione 1.1.0 con l'avviso certificati 2026-09-27 14:10:06 +02:00
davideandClaude Sonnet 5 0a2112a9f3 Porta la versione a 1.1.0 per l'avviso certificati (DD-035)
Aggiunta la sezione [1.1.0] nel changelog, spostata la voce da «Prossimo» a
«Fatto» nella roadmap e chiuso lo stato di DD-035 come implementata.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration m17_notifiche_utente non ancora applicata in produzione.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:50:54 +02:00
davideandClaude Sonnet 5 fcff950006 Bump versione a 0.9.2 e aggiorna il changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:41:46 +02:00
davideandClaude Sonnet 5 99ca3b6495 Rende ad accordion l'elenco Profili in admin: una sola scheda aperta
Lo stato "quale giocatore è aperto" sale a Dashboard invece di essere
locale a ogni SchedaGiocatore, così aprirne una chiude quella precedente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:38:33 +02:00
davideandClaude Sonnet 5 457a0fe23d Aggiunge copertura test e documentazione per Notifiche attive in admin
Estrae la deduplica degli id giocatore in idsConNotificheAttive
(src/lib/notifiche-attive.server.ts), testata a livello unit. Aggiunge
un test di integrazione sui permessi della route (401/403/200), sullo
stesso schema di permessi-route.test.ts. Documenta il nuovo endpoint
in notifiche.md e la dashboard admin a tab in profilo-giocatore.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:32:11 +02:00
davideandClaude Sonnet 5 d8fc6ef04f Aggiunge la sezione Notifiche in admin e converte la dashboard a tab
Nuovo endpoint admin-only /api/public/notifiche-attive per contare e
elencare i giocatori con almeno un dispositivo iscritto alle notifiche
push. La dashboard admin passa da sezioni impilate a un menu a pillole
scorrevole (BarraSottosezioni), come già fatto in Classifica e Squadra.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:23:30 +02:00
davideandClaude Sonnet 5 e99d853987 Rimuove la nota su chi vede i dati amministrativi dal profilo
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:10:00 +02:00
davideandClaude Sonnet 5 efca4243b6 Semplifica la label del campo email in admin: solo "Email"
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:07:22 +02:00
davideandClaude Sonnet 5 abd6fafc0c Rimuove le dipendenze da Lovable: l'app non gira più nel suo editor
Tolti il login social e il reporting errori verso l'editor Lovable (codice
morto, mai importato o no-op in produzione), la cartella .lovable/ con i
piani dell'editor, e riscritto vite.config.ts senza il preset
@lovable.dev/vite-tanstack-config, esplicitando gli stessi plugin
(TanStack Start, Tailwind, tsconfig-paths, React, Nitro/cloudflare) che
forniva.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:59:46 +02:00
davideandClaude Sonnet 5 d52ceeb0f9 Bump versione a 0.9.1 e aggiorna il changelog
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 07:51:03 +02:00
Ivan CacciariandCursor a9f146d77e Migliora le schede dello storico partite: loghi, casa-ospite e chevron.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 21:01:49 +02:00
davideandClaude Sonnet 5 04e80a3531 Allinea la documentazione al codice: migration, roadmap e moduli mancanti
Emerso da un audit doc↔codice: PROJECT_STATE.md era fermo a M12 (23 migration)
mentre supabase/migrations/ ne ha 27, fino a M16; ROADMAP.md non citava MVP,
Turno palloni, Infortuni e Profilo Giocatore come voci a sé pur essendo tutte
implementate; profilo-giocatore.md era l'unico modulo senza l'intestazione
Stato/File principali degli altri.

Aggiunge anche le due spec mancanti in docs/modules/: Squadra (anagrafica,
useRosa/useAnagraficaRosa, gestione admin, classifica interna) e Calendario ed
Eventi (vista mensile vs gestione admin, pulizia a cascata alla cancellazione).

Corregge inoltre la nota sul versionamento in CHANGELOG.md: sempre a tre cifre
(x.y.z), mai x.y.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:31:39 +02:00
davideandClaude Sonnet 5 5569d08b59 Aggiunge il dettaglio esteso di partita: formazioni e scontri diretti dal CSI
In Campionato > Storico partite ogni gara diventa cliccabile: verso /partita/$id
se collegata a un evento CrAPP, verso la nuova /partita-csi/$id altrimenti (nessuna
creazione automatica di eventi dal calendario CSI, quindi molte gare ne sono prive).

Il dettaglio aggiunge formazioni (titolari/panchina/staff), storico scontri diretti
e probabilità di vittoria calcolata dal CSI, letti on-demand da tre nuovi endpoint
per-partita (match-main/players/stats.php) dietro /api/public/csi-partita/$id, con
cache server per-partita separata da quella di /api/public/csi — è un dato aperto a
richiesta, non precaricato per tutti come classifica e partite.

Include anche logo squadra e metadati leggeri (girone, n° gara, arbitro, link al
referto ufficiale) già presenti nel JSON esistente, a costo zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:07:03 +02:00
davideandClaude Sonnet 5 c628997934 Mostra anche la classifica di Coppa in Campionato > Classifica
parseClassifica() legge project-sheets.php?project_id=848 con lo stesso
parsing del girone di campionato (stessa struttura di tabella), quindi
nessun parser dedicato. La Coppa resta limitata alla fase a gironi: la
finale secca vive in un project_id figlio non tracciato dal codice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:29:34 +02:00
davideandClaude Sonnet 5 8353761b6e Corregge il dettaglio MVP in classifica: solo partite, non tutti gli eventi
Il sottotitolo "N partite giocate" per il criterio MVP usava totaliEventiGiocatore(),
che conta anche gli allenamenti: un numero fuorviante perché l'MVP si vota solo alle
partite del calendario interno (eventi_app), non a quelle del sito CSI. Aggiunge
contaPartiteGiocate() (presenze.ts) e Giocatore.partiteGiocate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:02:38 +02:00
davideandClaude Sonnet 5 15f058f760 Corregge la classifica interna di Squadra a parità di punteggio
I giocatori a pari valore condividono ora la stessa posizione (dense rank) invece di
essere numerati in sequenza, e il sottotitolo di ogni riga segue il criterio scelto
invece di mostrare sempre le presenze consecutive. Per Palloni mostra le volte
consecutive in cui il giocatore li ha portati (nuovo Giocatore.seriePalloni).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:55:06 +02:00
davideandClaude Sonnet 5 8dfd81ebe4 Estende bonifica-evento.test.ts a tutte e 9 le tabelle collegate
Il test copriva solo 3 delle 9 istruzioni DELETE dentro
bonifica_dati_evento_orfani() (risposte_presenze, mvp_voti,
pagelle_voti), lasciando cacche_partita, turni_palloni,
scout_sessioni, scout_live, scout_partite e badge_social_voti senza
verifica automatica. Ora inserisce una riga orfana in tutte e sei le
tabelle evento_id e una riga orfana più due storiche (Scout, CSI) in
tutte e tre le tabelle match_id, verificando la stessa distinzione
delicata su ciascuna, non solo su un sottoinsieme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:37:01 +02:00
davideandClaude Sonnet 5 bef5542ce3 Rende testabile la bonifica di M15 come funzione RPC (M16)
M15 ha ripulito una tantum le righe orfane con un blocco di DELETE
verificato solo a mano, senza lasciare nessuna rete di sicurezza
automatica per il futuro. Questa migration rende lo stesso corpo la
funzione bonifica_dati_evento_orfani(), riservata al service role, così
resta richiamabile se il trigger di M14 smettesse mai di funzionare.

Aggiunge 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. Documenta l'aggiornamento in
DESIGN_DECISIONS.md (DD-029), DATABASE.md, test/README.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:32:16 +02:00
davideandClaude Sonnet 5 57463f993b Bonifica una tantum le righe orfane da eventi cancellati prima di M14
M14 (DD-029) pulisce a cascata i dati collegati solo per le cancellazioni
future; questa migration ripulisce lo storico. Per risposte_presenze,
cacche_partita, turni_palloni, scout_sessioni, scout_live e scout_partite
elimina ogni riga il cui evento_id non esiste più in eventi_app.

Per mvp_voti, pagelle_voti e badge_social_voti serve più cautela: prima
del passaggio all'id evento CrAPP, match_id conteneva id Scout
(prefisso "s") o CSI (numerico o data-based), voti storici legittimi
mai collegati a un evento CrAPP. Il filtro si applica solo ai match_id
nel formato di nuovoIdEvento() ("e" + timestamp base36), così i vecchi
voti Scout/CSI restano intatti. Verificato manualmente sul database
locale prima dell'applicazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:24:43 +02:00
davideandClaude Sonnet 5 c7cd527045 Pulisce a cascata i dati collegati quando un evento viene cancellato (DD-029)
Un audit del modulo Obiettivi ha verificato che gli obiettivi non hanno
bisogno di pulizia: sono ricalcolati a runtime sull'elenco eventi
corrente. Il problema reale era un livello sotto: cancellare un evento
lasciava orfane le righe collegate in 8 tabelle (risposte_presenze,
cacche_partita, mvp_voti, pagelle_voti, badge_social_voti,
turni_palloni, scout_sessioni, scout_live, scout_partite), nessuna con
una foreign key verso eventi_app.

Aggiunge un trigger AFTER DELETE su eventi_app (migration
m14_pulizia_dati_evento_cancellato) invece di una FK con CASCADE, non
applicabile per dati storici disallineati (match_id che a volte punta
a id Scout/CSI). Verificato con un nuovo test di integrazione contro
il database locale; documentato in DESIGN_DECISIONS.md, DATABASE.md,
obiettivi-squadra.md, test/README.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:16:29 +02:00
davideandClaude Sonnet 5 00235d1594 Applica una soglia minima di campione a Media voto e MVP in home (DD-028)
Rimuove l'hint statico "+2 questo mese" dalla StatTile Presenze. La StatTile
Media voto usa ora la stessa soglia minima di voti del badge Pagellone
(VOTI_MINIMI_PAGELLA), tramite la nuova funzione pura testabile
mediaVotoColpoDOcchio(). L'MVP di partita richiede un quorum minimo di 2
voti totali (VOTI_MINIMI_MVP) oltre al margine netto già richiesto: un
solo voto non assegna più la vittoria, con effetto anche sui conteggi
già mostrati. Aggiorna test unitari e di integrazione, e documenta la
decisione in DESIGN_DECISIONS.md, pagelle.md, mvp.md e CHANGELOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 09:56:56 +02:00
davideandClaude Sonnet 5 6d177d5d8a Documenta le StatTile home e rimuove i riferimenti obsoleti a v1.0/v1.1/v2.0
Aggiunge alle doc di presenze/pagelle il collegamento con le StatTile di
home «Colpo d'occhio» e registra nel changelog la rimozione dell'hint
statico. Uniforma i moduli allo stato "implementato" senza tag di
versione e riscrive in DESIGN_DECISIONS.md i riferimenti a v1.0/v1.1/v2.0
con descrizioni neutre, coerenti con il fatto che il progetto non usa
più quella numerazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 09:32:19 +02:00
Ivan CacciariandCursor 89b08349c4 Rinomina la tab Stagione in Season sul Profilo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:19:45 +02:00
Ivan CacciariandCursor f1d61ebd6e Allinea le tab del Profilo a una sola riga a larghezza uguale.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:17:49 +02:00
Ivan CacciariandCursor dbf60d3a7e Affina Profilo e Squadra: logo home, tab Docs uguali, separatore classifica.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:10:22 +02:00
Ivan CacciariandCursor b0c1e2f826 Rinomina la tab Statistiche in Stats su Squadra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:47:56 +02:00
Ivan CacciariandCursor 0eb2d60569 Sposta la classifica interna in Statistiche e uniforma le tab Squadra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 08:47:56 +02:00
davideandClaude Sonnet 5 3b305b0f8e Completa la copertura test di tutti i badge e corregge due bug trovati in audit
Audit dedicato su tutti i 16 badge: s-tiebreak non applicava la soglia minima di
voti pagella di Pagellone sullo stesso campo mediaVoto (corretto), s-cacche
prometteva "partite di campionato" senza che il codice lo verificasse mai
(corretta la descrizione, comportamento invariato). Aggiunti test unit e
integration end-to-end mancanti su badge segreti e social, con dati scritti a
database anche per i cinque segreti che prima ne erano privi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:36:33 +02:00
davideandClaude Sonnet 5 a451d4187f Completa la copertura test del badge Risposta lampo e ne documenta il comportamento
Analisi della logica di serieConferme() senza bug trovati; aggiunte le soglie
esplicite 3/8/15 e i rami mancanti (convocati ristretti, tipo misto, bordo
esatto delle 24h, evento futuro), più un integration test end-to-end che
verifica la mappatura creato_il/risposto_il con dati reali.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:07:59 +02:00
davideandClaude Sonnet 5 8a692bfe2f Documenta il salto automatico dei test CSI quando il portale non risponde
Aggiunto un punto ai Limiti noti di collegamento-csi.md: il portale può essere
del tutto irraggiungibile (non solo cambiare formato, già coperto dal punto 5),
con l'episodio dell'8 settembre 2026 come esempio verificato. Spiega perché
api.test.ts salta quei 5 test invece di farli fallire, e che tornano a girare da
soli quando il CSI risponde di nuovo. Aggiunta la stessa nota, più breve, in
test/README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:48:33 +02:00
davideandClaude Sonnet 5 cde01ff02b Salta (invece di fallire) i test CSI in api.test.ts quando il portale non risponde
Il portale CSI (livescore.csibologna.it) è momentaneamente in manutenzione lato
loro (risponde con un errore SQL in chiaro al posto del JSON su
getEventsByTeamId.php). I 5 test che dipendono dal CSI reale ora sondano una
volta sola /api/public/csi: se non è raggiungibile vengono saltati con salta()
invece di fallire, senza toccare la loro logica né cancellarli — tornano a girare
non appena il portale è di nuovo su.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:46:54 +02:00
davideandClaude Sonnet 5 d250f41749 Il badge Sherpa dei palloni conta solo i turni confermati, non le proposte
rosa.ts calcolava Giocatore.palloni su completaTurni() (turni salvati + proposte
automatiche di rotazione non ancora confermate da nessuno): un giocatore poteva
avanzare nel badge senza aver mai confermato un turno, solo perché l'algoritmo lo
proponeva per un evento passato. Ora passa a conteggioTurni() solo turniSalvati
(i turni davvero confermati in turni_palloni); completaTurni() resta in uso solo
per la UI di rotazione (TurnoPalloni.tsx, PromemoriaPalloni.tsx). Corregge anche
la StatTile "quante volte hai portato i palloni" nel profilo, che leggeva lo
stesso valore.

Aggiornato palloni-badge.test.ts per verificare il nuovo comportamento (un evento
non confermato non conta più per nessuno) invece del vecchio. docs/modules/
badge.md e palloni.md aggiornati di conseguenza, spostando la voce da "Limiti
noti" a "risolto".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:43:58 +02:00
davideandClaude Sonnet 5 4fb05748c3 Completa la copertura test del badge Sempre in palestra e ne documenta il comportamento
Nessun bug trovato: serieConsecutiva() gestisce correttamente buco (azzera) vs
infortunio (congela senza azzerare), lo stesso filtro sui convocati della funzione
pura protegge anche questo badge senza bisogno dell'estensione RLS di M13.
Confermato che il limite noto "risposto_il non ricostruibile prima di m9" riguarda
solo serie-conferme e il segreto s-mai-forfait, non questo badge: serieConsecutiva()
guarda solo lo stato della risposta, non il suo istante.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(serie-allenamenti-badge.test.ts) che scrive allenamenti e risposte reali sul
database locale e verifica l'intera pipeline fetchPresenze()/daRiga() ->
serieConsecutiva() -> statoBadge() attraverso le tre soglie, incluso il caso "un
buco azzera" e, separatamente, "un infortunio congela invece di azzerare".
docs/modules/badge.md aggiornato con la pipeline, la copertura test e la
descrizione corretta (l'infortunio non spezza la serie).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:39:26 +02:00
davideandClaude Sonnet 5 10d1455eff Completa la copertura test del badge Presenza fissa e ne documenta il comportamento
Nessun bug trovato: contaPresenzeGiocatore() filtra correttamente per convocati,
tipo evento e stato (presente/ritardo contano, assente/forse no), gli eventi
futuri restano esclusi. Confermato che, a differenza di MVP/pagelle/badge social,
questo badge non ha bisogno dell'estensione RLS di M13: il filtro sui convocati è
già nella funzione pura, non solo in UI.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(presenze-badge.test.ts) che scrive eventi e risposte reali sul database locale e
verifica l'intera pipeline fetchPresenze()/daRiga() -> contaPresenzeGiocatore() ->
statoBadge() attraverso le tre soglie, incluso il caso "non convocato, la riga
esiste ma non conta". docs/modules/badge.md aggiornato con la pipeline, la
copertura test e la correzione della dicitura "in stagione" (il conteggio è
sull'intero storico, coerente con gli altri badge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:33:51 +02:00
davideandClaude Sonnet 5 e6a222f196 Completa la copertura test del badge Sherpa dei palloni e ne documenta il comportamento
Nessun bug nella logica di calcolo: soglie 3/6/10 corrette, gradoRaggiunto()/
statoBadge() si comportano come per gli altri badge da contatore. Trovato però un
comportamento reale ma non ovvio (già accennato in palloni.md, mai dimostrato con
dati veri): il conteggio del badge include anche le proposte automatiche di
completaTurni() non ancora confermate da nessuno, non solo i turni salvati in
turni_palloni.

Aggiunti test unit sulle soglie (badges.test.ts) e un nuovo end-to-end
(palloni-badge.test.ts) che scrive eventi/turni parzialmente confermati sul
database locale e verifica l'intera pipeline fetchTurni() -> completaTurni() ->
conteggioTurni() -> statoBadge(), incluso il caso "evento futuro non conta".
docs/modules/badge.md aggiornato con la pipeline, la copertura test e il limite
noto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:26:41 +02:00
davideandClaude Sonnet 5 1fdc6720a0 Sistema tutti gli errori di lint (prettier) rimasti su main
npx eslint --fix su test/unit/obiettivi.test.ts, test/integration/obiettivi.test.ts,
BarraSottosezioni.tsx, EventoCard.tsx e calendario.tsx — solo formattazione,
nessuna modifica di comportamento. Verificato con la suite unit (32/32 verde).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:12:49 +02:00
davideandClaude Sonnet 5 080379cc64 Sistema i buchi del badge Pagellone: soglia minima voti e voto limitato ai convocati
Il badge Pagellone si sblocca ora solo con almeno 5 pagelle ricevute
(VOTI_MINIMI_PAGELLA): un voto solo poteva sbloccarlo o farlo sparire senza
significatività statistica. Aggiunto Giocatore.votiPagella per farlo funzionare.

La migration m13 (DD-027) estende le policy RLS di M11 su pagelle_voti, mvp_voti e
badge_social_voti: votante e votato devono essere convocati all'evento (prima solo
un filtro applicativo), e per le pagelle anche pagelle_chiuse=false. Le policy
admin restano permissive di proposito. Applicata anche al progetto cloud.

Copertura test completa: unit sulla soglia minima, integration sulle nuove policy
RLS (permessi.test.ts) e un end-to-end reale (pagella-badge.test.ts) sul modello
di mvp-badge.test.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:08:52 +02:00
davideandClaude Sonnet 5 8c9cf370e8 Toglie duplicazioni dalla sezione Implementazione di badge.md
Il bullet MVP ripeteva per intero il meccanismo di voto già documentato in mvp.md
(e con un'imprecisione: l'RLS di m11 non è una barriera contro l'autovoto, impedisce
solo di votare a nome di un altro). Ora rimanda a mvp.md e tiene solo ciò che serve
a capire il badge. Tolto anche il bullet iniziale sui segreti, che ripeteva parola
per parola l'intro della sezione "Elenco badge".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:44:00 +02:00
davideandClaude Sonnet 5 c7588c5aa2 Completa la copertura test del badge MVP e riscrive la doc del modulo badge
Aggiunge il caso positivo RLS mancante per mvp_voti in permessi.test.ts e un nuovo
test end-to-end (mvp-badge.test.ts) che verifica l'intera pipeline voti reali ->
mvpVintiPerGiocatore() -> statoBadge(). docs/modules/badge.md ora elenca tutti e 16
i badge con come funzionano, la copertura test badge per badge e i due problemi
minori trovati in audit (badgeSbloccati() morta, categoria senza vincolo DB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 13:38:30 +02:00
davideandClaude Sonnet 5 9b81fcf2fc Riscrive la doc del modulo obiettivi di squadra riflettendo lo stato attuale
Rende la doc autosufficiente: tabella completa dei 10 obiettivi con id, calcolo
e target; sezione dedicata alla dipendenza dal JSON CSI per le vittorie (o3/o4/o5)
con il limite del parsing silenzioso e i passi per fixarlo se il portale cambia
formato; tabella di copertura test per ogni obiettivo con i file esatti.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:06:37 +02:00
davideandClaude Sonnet 5 242941740b Cappa le vittorie di "10 vittorie in campionato" e completa la copertura test
o5 non aveva il Math.min(vittorie, 10) dei fratelli o3/o4: il progresso
mostrato non cambiava (già cappato al 100%), ma il valore grezzo sì, mostrato
senza cap in squadra.tsx/index.tsx (es. "15/10 vittorie" invece di "10/10").

Nessuno dei tre obiettivi aveva test. Le vittorie non toccano Supabase -
arrivano dal portale CSI via /api/public/csi - quindi l'integration test
estende test/integration/api.test.ts (che già chiama quella route dal vivo)
invece di test/integration/obiettivi.test.ts, verificando o3/o4/o5 sui dati
CSI reali del giorno.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:01:43 +02:00
davideandClaude Sonnet 5 7e51fe6345 Segnala in modo visibile se il portale CSI cambia formato
Le vittorie in campionato dipendono dal JSON delle partite (non dalla
classifica HTML, come erroneamente documentato prima). Se quel formato cambia,
il parsing fallisce in silenzio: nessun errore, nessuna notifica, le vittorie
restano ferme a 0% finché qualcuno non se ne accorge per caso.

Aggiunta partiteFormatoSospetto() in csi-core.ts: confronta gli eventi grezzi
con il risultato del parsing per distinguere un vero "formato cambiato" da un
legittimo "nessuna gara ancora in programma". La route logga l'errore server e
il flag arriva fino a /classifica, dove sostituisce la riga "Dati CSI
aggiornati alle..." con un badge discreto color warning - visibile a chi apre
la pagina, senza notifiche invasive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:01:32 +02:00
davideandClaude Sonnet 5 8c6fceb682 Documenta e copre con test l'obiettivo "Continuità di squadra"
Il target 12 non è un valore arbitrario da scalare con la rosa come gli altri
target fissi: è il minimo di giocatori per schierare due sestetti (6vs6) in
allenamento, confermato intenzionale. Documentato in
docs/modules/obiettivi-squadra.md.

Non aveva alcun test. Aggiunti unit test (rosa vuota, confine >= 3, target
fisso) e un integration test end-to-end che scrive tre allenamenti e presenze
reali sul database locale, calcola serieConsecutiva() (la stessa funzione pura
usata da useRosa() in produzione) sui dati riletti, e verifica il conteggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:34:50 +02:00
davideandClaude Sonnet 5 0dadabfea6 Completa la copertura test dell'obiettivo "200 pagelle compilate"
C'era una sola asserzione (2 pagelle -> 2), nessun caso vuoto esplicito e
nessun integration test. Aggiunti il caso vuoto, il conteggio aggregato su
più match_id, e l'assert su o13 nell'integration test già scritto per la
media pagelle (stessi voti reali scritti su pagelle_voti, zero setup extra).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:29:13 +02:00
davideandClaude Sonnet 5 261578357a Completa la copertura test di o1, o2 e aggiunge quella di "Media pagelle da 7.5"
o1 e o2 avevano solo test con un evento singolo per volta: mancavano rosa vuota
(divisione per zero), verifica che le partite contino come gli allenamenti con
eventi sociali/compleanni esclusi, e l'aggregazione su più eventi dello stesso
mese. Aggiunti unit e integration test per entrambi.

"Media pagelle da 7.5" aveva solo il caso vuoto e un caso che faceva già media
esatta: aggiunti test sull'arrotondamento a una cifra decimale e
sull'aggregazione di voti da più partite, più un integration test che scrive
voti veri su pagelle_voti rispettando i vincoli reali della tabella.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:25:49 +02:00
davideandClaude Sonnet 5 447016f03b Completa la copertura test dell'obiettivo "250 presenze complessive"
C'era solo un confronto circolare (la stessa formula ricalcolata sugli stessi
dati reali) più il caso rosa vuota. Aggiunto un unit test con roster piccolo e
valori noti (5 + 10 + 15 = 30), e un integration test end-to-end che scrive
eventi/risposte veri sul database locale, calcola contaPresenzeGiocatore() (la
stessa funzione pura usata da useRosa() in produzione) sui dati riletti, e
verifica che obiettiviSquadra() sommi correttamente il risultato.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:19:27 +02:00
davideandClaude Sonnet 5 e13722eae9 Rende dinamica anche la scadenza di "Tutti rispondono alle convocazioni"
La scadenza era una data fissa (2026-09-30), stesso problema già risolto per
"presenze del mese" e "evento di squadra al mese": ora segue l'ultimo giorno
del mese corrente. Aggiunti unit test e integration test end-to-end contro il
database locale (isolando gli eventi di test, perché questo obiettivo aggrega
su tutti gli eventi indipendentemente dal mese).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:14:57 +02:00
davideandClaude Sonnet 5 2b30dd4708 Rende dinamico il mese degli obiettivi di squadra invece di una costante fissa
"Presenze del mese" e "evento di squadra al mese" erano ancorati a una costante
MESE = "2026-08": passato agosto restavano congelati sul mese scorso invece di
azzerarsi a ogni cambio mese. Ora il mese di riferimento (e per il primo anche
titolo e scadenza) segue la data corrente, con un parametro `oggi` iniettabile
per test deterministici. Aggiunti unit test e un integration test end-to-end
contro il database locale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:00:31 +02:00
davideandClaude Sonnet 5 440bf0cd56 Rimuove todo.md: appunti di lavoro della sessione, non documentazione di progetto
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:14:39 +02:00
davideandClaude Sonnet 5 8add483a5b Precarica il chunk delle pagine al touchstart sui tab della BottomNav
Nessuna rotta aveva preload configurato: il chunk JS di una pagina iniziava a
scaricarsi solo a tap completato, mai prima. Con 4 tab piccole (8-12KB l'una) e
sempre visibili, non c'e' motivo di aspettare il tap per iniziare.

preload="intent" sui 4 Link: parte al touchstart, prima che il tap sia completo.
Precarica solo il codice, non i dati (nessuna rotta usa loader), quindi nessun
costo aggiuntivo di letture verso Supabase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:10:05 +02:00
davideandClaude Sonnet 5 3258a24f20 Estende il fix di useIo/useRosa a EventoCard e altri 13 usi non statistici
EventoCard monta un hook per ogni card mostrata: usava useGiocatoreCorrente
(= useIo = useRosa, le 6 statistiche pesanti) solo per leggere il proprio id.
Il Calendario ne rende diverse insieme (prossimi eventi, compleanni, drawer del
giorno), quindi ogni apertura moltiplicava il ricalcolo dell'intera rosa.

Audit di tutti gli altri usi di useGiocatoreCorrente: 12 su 13 leggevano solo
id/nome/verita', mai una statistica. Corretti allo stesso modo (-> useGiocatoreBase):
benvenuto.tsx, VotazioneMvp, SondaggioCacche, VotoSocial, eventi.tsx,
PromemoriaPalloni (Home), TurnoPalloni, ScoutEntry, Pagelle.

partita.$id.tsx: `io` era dichiarato e mai piu' usato, rimosso.

Stesso pattern trovato anche su useRosa (non solo useGiocatoreCorrente) in
scout.tsx e RosaPresenze.tsx (montata su partita e allenamento): entrambi
passati al nuovo useAnagraficaRosa, esteso con ruolo e numero.

Unica eccezione: CelebrazioneBadge.tsx usa davvero le statistiche complete
(badge, MVP, serie) ed e' montato globalmente in __root.tsx - non downgradabile,
resta il costo di base piu' alto rimasto in giro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:00:52 +02:00
davideandClaude Sonnet 5 82b6c6b333 Evita di calcolare le statistiche di tutta la rosa dove serve solo l'anagrafica
LinkProfilo (header di ogni pagina) e il Calendario (compleanni del mese) usavano
useIo/useRosa, che calcola MVP, pagelle, cacche, palloni e infortuni per l'intera
squadra tramite 6 hook e un useMemo pesante - anche se a entrambi serviva solo
id/nome/nascita/iniziali. Il Calendario in particolare pagava questo costo ad ogni
apertura solo per i compleanni del mese.

- crapp-data.ts: esporta inizialiDa, già usata internamente per il fallback avatar.
- ui-bits.tsx: LinkProfilo usa useGiocatoreBase (sola anagrafica) invece di useIo.
- rosa.ts: nuovo hook useAnagraficaRosa (id, nome, nascita) senza gli altri 5 hook
  statistici ne' il useMemo su tutta la rosa.
- eventi.ts: compleanniEventi accetta solo i tre campi che usa, non l'intero
  Giocatore.
- calendario.tsx: usa useAnagraficaRosa al posto di useRosa.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:47:31 +02:00
davideandClaude Sonnet 5 08b00627e5 Riduce il lag su Android: tab memoizzate, vetro/blur/drag disattivati sui device deboli
- squadra.tsx e classifica.tsx: il contenuto di ogni tab di BarraSottosezioni è ora
  memoizzato con useMemo, cosi' uno stato locale o un refetch in background non
  ricalcolano piu' anche le sezioni nascoste.
- BottomNav, BarraSottosezioni, calendario: useMotoRidotto (RAM bassa o
  prefers-reduced-motion) disattiva sui device deboli la catena di filtri SVG piu'
  pesante di .vetro, il backdrop-blur della barra tab e il drag orizzontale;
  su iOS e device performanti resta tutto invariato.
- styles.css: rimossa l'utility .materiale, orfana da quando BottomNav e' passata
  a .vetro.
- ui-bits.tsx: decoding="async" su TeamLogo.
- todo.md: traccia le cause individuate e lo stato di applicazione di ciascuna.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 09:39:40 +02:00
Ivan CacciariandCursor fc65914f55 Fa riempire tutta la larghezza alle tab di Campionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 18:35:35 +02:00
Ivan CacciariandCursor 8e23f31685 Rinomina Classifica in Campionato e allinea i link azione in home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 18:13:10 +02:00
Ivan CacciariandCursor f5aaa1cccb Evita che le tab Squadra allarghino la pagina: scroll solo sulla barra.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 17:49:40 +02:00
davideandClaude Sonnet 5 b4878b7795 Chiude il sondaggio cacche al fischio d'inizio, non solo il giorno dopo
sondaggioAperto() restava aperto per sempre una volta passate le 8:00 del
giorno della partita, anche nei giorni successivi. Ora chiude esattamente
all'ora di inizio (partita alle 21:00 → aperto fino alle 20:59, poi chiuso
anche nei giorni seguenti), richiedendo anche l'ora dell'evento oltre alla
data.

Aggiunta sondaggioTerminato() per distinguere nel messaggio "non ancora
aperto" da "già chiuso": altrimenti dopo la partita l'utente avrebbe
continuato a leggere "apre alle 8:00".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:50 +02:00
davideandClaude Sonnet 5 9cc63b7854 Infortuni/ritardi: non contano più gli eventi futuri nei badge
contaInfortuni()/contaRitardi() leggevano tutta la mappa presenze senza
guardare la data dell'evento: un giocatore può segnarsi infortunato o in
ritardo anche su un evento non ancora avvenuto (l'UI lo permette finché
l'evento non è passato), e quel conteggio arrivava subito ai badge segreti
"Infermeria" e "Ritardi". Ora filtrano su data < oggi, come già fa
eventiContanoPresenze() per le statistiche di presenza: con l'avanzare
della data reale l'evento prima futuro entra da solo nel conteggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:50 +02:00
davideandClaude Sonnet 5 e7b5082398 Squadra: un solo controllo per il criterio della classifica interna
La select nativa e il bottone-drawer cambiavano lo stesso criterio in due modi
contemporaneamente, effetto ridondante e poco curato. Ora un'unica riga
tappabile mostra il criterio attivo e apre il drawer di scelta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:15:17 +02:00
davideandClaude Sonnet 5 446403cdf2 Menu sottosezioni "sottolineatura": pillola piena sulla tab attiva, su tutta larghezza
La sola sottolineatura era troppo minimale sulla tab attiva. Ora è uno sfondo
pieno arrotondato come le pillole del Profilo, ma le tab restano giustificate
su tutta la larghezza dello schermo senza scroll orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:27:47 +02:00
davideandClaude Sonnet 5 a7ae9ed3a4 Rimuove dalla home gli eventi già confermati dalla lista "da confermare"
L'elenco si basava solo sull'ordine cronologico dei prossimi eventi,
ignorando la risposta di presenza già data dall'utente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:09:55 +02:00
Ivan CacciariandCursor ed289b9ecd Ingrandisce i titoli delle sezioni a 20px.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:42:08 +02:00
Ivan CacciariandCursor b92a167b5a Limita a 3 i prossimi eventi nel calendario.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:40:03 +02:00
Ivan CacciariandCursor a06c5ed281 Semplifica il calendario e mostra le note solo sulle card Evento.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:38:03 +02:00
Ivan CacciariandCursor 10915d04fc Affina EventoCard: stato sopra i bottoni, note solo in dettaglio, badge Evento.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:24:58 +02:00
Ivan CacciariandCursor a459579f8b Ingrandisce i testi del widget profilo e del link Calendario in home.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 11:03:47 +02:00
Ivan CacciariandCursor a6799b91d3 Ridisegna EventoCard e toglie infortunato dagli eventi extra-campo.
Card cliccabile con icone presenza compatte, senza barra percentuale; avatar home leggermente più grande.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 10:58:57 +02:00
davideandClaude Sonnet 5 c44fd8495e Non conta i turni palloni finché l'evento non è passato.
conteggioTurni() contava ogni turno presente nella mappa, incluse le
assegnazioni anticipate per eventi futuri: un allenamento di lunedì con
turno già assegnato oggi (venerdì) veniva già sommato al badge "Sherpa dei
palloni" prima ancora di essersi svolto. Ora filtra su e.data < oggi, lo
stesso criterio già usato per presenze e serie — il turno resta assegnabile/
modificabile in anticipo, semplicemente non conta finché il giorno non arriva.

oggiISO() (palloni-core.ts) diventa un alias di dataOggi(): stessa correzione
di fuso della commit precedente, qui serviva anche per admin.tsx (scadenza
documenti/certificati) e cacche.ts (statistiche giornaliere), che la usano.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:59:03 +02:00
davideandClaude Sonnet 5 7ef7eef963 Calcola "oggi" nel fuso di Roma ovunque, invece di UTC o del fuso del processo.
serieConsecutiva()/serieConferme() calcolavano oggi con new Date().toISOString()
(sempre UTC), mentre contaPresenzeGiocatore() usava dataOggi() con i getter
locali di Date (corretti solo se il processo gira già in fuso italiano — falso
su un server SSR in UTC). Le due statistiche potevano non essere d'accordo su
cosa fosse "oggi" nelle prime ore della giornata italiana.

dataOggi() ora usa Intl.DateTimeFormat con timeZone: "Europe/Rome": il cambio
ora legale/solare lo gestisce il database IANA dei fusi, non un offset scritto
a mano. Le funzioni di serie in presenze.ts usano lo stesso dataOggi() invece
di un oggiIso() locale, così tutte le statistiche restano coerenti fra loro.

Aggiunti test che dimostrano il fix con istanti reali a cavallo di mezzanotte
sia in CET che in CEST, per provare che lo scarto segue davvero il fuso e non
un offset fisso.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:47:02 +02:00
davideandClaude Sonnet 5 8cd0f1dc88 Congela la serie di presenze su infortunato invece di azzerarla.
L'evento in cui il giocatore risulta infortunato viene ora escluso a monte dal
calcolo di serieConsecutiva() (come già succede per gli eventi senza creatoIl
in serieConferme()): non conta né come presenza né come buco, la serie resta
al valore precedente. contaPresenzeGiocatore() non cambia: infortunato continua
a non contare come presenza nelle statistiche.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:31:31 +02:00
davideandClaude Sonnet 5 b26d382c11 Sostituisce il confirm() nativo con un drawer per eliminare un evento.
Coerente con lo stile dell'app (stesso componente Drawer usato per i badge)
invece del popup del browser: mostra il titolo dell'evento e richiede un
tocco esplicito su "Elimina" prima di procedere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:11:11 +02:00
davideandClaude Sonnet 5 9e2f83a85e Corregge il rimbalzo di /eventi in home e la lista invisibile con molti eventi.
Il redirect verso /benvenuto scattava se la rosa non era ancora arrivata dalla
rete, anche con sessione e profilo validi: caricare /eventi a freddo (refresh,
link diretto, riapertura della PWA) ti riportava sempre in Home. Ora si aspetta
il caricamento della rosa e si torna alla pagina di destinazione originale.

In Reveal, la soglia del 15% dell'altezza dell'elemento per attivare l'animazione
era irraggiungibile per una lista lunga (75 eventi = oltre 5700px): la sezione
restava invisibile per sempre, a qualunque scroll. La soglia ora è "some" (basta
un pixel visibile).

Aggiunta anche la gestione dell'errore sul caricamento eventi, che prima falliva
in silenzio mostrando una pagina vuota senza alcun messaggio.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 08:59:10 +02:00
Ivan CacciariandCursor a1d2599c47 Rinomina il link evento in Dettagli.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:54:03 +02:00
Ivan CacciariandCursor ca1eee5425 Ripulisce profilo e tab, e mette Apri evento sull'ultima riga delle presenze.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:48:21 +02:00
Ivan CacciariandCursor 6bc5e8f006 Ingrandisce i caratteri delle tab sottosezioni e dei controlli calendario.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:24:59 +02:00
davideandClaude Opus 5 ea9f17e8d2 Riscrive changelog e roadmap: Keep a Changelog e niente doppia numerazione.
Il changelog aveva sezioni narrative sotto «Versione attuale», che non diceva
quale versione, e una «Versione 1.0 — luglio 2026» data per rilasciata mentre il
pacchetto è alla 0.9.0 ancora da distribuire. Ora la testa è
`[Non rilasciato] — 0.9.0` in formato Keep a Changelog: essendo la prima
versione tutto è Aggiunto, con la sola Sicurezza a parte — niente Modificato,
Rimosso o Corretto, che presuppongono qualcosa già uscito.

La roadmap perde i numeri di versione (1.0, 1.1, 1.2, 2.0) e diventa
Fatto / Prossimo / Idee future: numerava per traguardo funzionale mentre il
changelog numera i rilasci, e tenere allineate due numerazioni diverse per lo
stesso progetto è lavoro che nessuno farà. Le versioni ora stanno solo nel
changelog. Nessuna voce persa.

Aggiornati i rimandi rimasti indietro: la regola di manutenzione e l'indice in
docs/README.md, la citazione «CHANGELOG v1.0.6» e la nota su AI Allenamenti in
DESIGN_DECISIONS.md, «la v1.1 è completa» in PROJECT_STATE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:23:35 +02:00
davideandClaude Opus 5 7e65df1d21 Toglie TODO.md e VISION.md dalla documentazione.
Erano due file che nessuno teneva più allineati: `TODO.md` ripeteva lo stato di
`PROJECT_STATE.md` e il backlog della roadmap, `VISION.md` la missione già
riassunta in apertura di `AGENTS.md`. La manutenzione stagionale del
collegamento CSI, unica voce senza altra casa, è già tra i limiti noti del
modulo `collegamento-csi.md`.

Aggiornati i sei rimandi: indice, ordine di lettura e regole di manutenzione in
docs/README.md, la missione e l'elenco dei documenti di tracciabilità in
AGENTS.md, la nota sul _cosa_ in ROADMAP.md — che ora punta a PROJECT_STATE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:17:44 +02:00
davideandClaude Opus 5 f149d02e9a Apre il voto MVP due ore dopo l'inizio e lo riserva ai presenti.
La votazione non dipendeva dal tempo ma dal risultato: compariva solo con un
referto CSI o uno scout salvato, e chiunque avesse un profilo poteva votare.
Ora `votoMvpAperto()` la apre due ore dopo `data`+`ora` dell'evento, il pannello
sta in una sezione sua e votano — e sono votabili — solo i presenti (o in
ritardo) di quell'evento.

I voti passano dall'id scout/CSI a quello dell'evento CrAPP: home e classifica
leggono il vincitore tramite la mappa data → evento che avevano già. Le regole
sono applicative, non RLS: chi scrive su PostgREST le aggira, come già annotato
tra i limiti noti del modulo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 22:11:26 +02:00
davideandClaude Opus 5 da669db1eb Chiama il pacchetto crapp e porta la versione a 0.9.0.
Il nome era ancora `tanstack_start_ts`, quello dello starter da cui è nato il
progetto. Non lo vede nessun utente — il pacchetto è privato, e il nome che
leggono i giocatori sta in public/manifest.webmanifest e in __root.tsx — ma
è la prima riga che si apre guardando il repository.

Aggiornata anche la riga corrispondente in bun.lock, così il prossimo
`bun install` non ha niente da riscrivere. Nessuna dipendenza si risolve per
nome del workspace root: lint, test e build passano.

`package-lock.json` resta indietro di proposito: è fermo a fine agosto e il
progetto installa con bun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:52:31 +02:00
davideandClaude Opus 5 7c1f08d943 Copre la cache delle presenze, le tabelle aperte e le letture server.
Chiude i tre buchi di copertura rimasti dalla rilettura della documentazione:
comportamenti che la doc descrive come regole ma che nessun test verificava.

- conRisposta() esce da useSalvaPresenza e diventa una funzione pura in presenze.ts.
  La mutation non rilegge dopo la scrittura, quindi la cache deve imitare il database:
  l'istante si scrive solo se manca (??=) e il ritiro della risposta lo cancella. Erano
  due dettagli che serie-presenze.md chiede di preservare e che un refactor poteva
  perdere in silenzio, falsando la serie "Conferme 24h" fino al refresh successivo.
- permessi.test.ts copre ora anche il terzo gruppo di DD-023, le tabelle lasciate
  aperte di proposito: scout_sessioni, scout_live, scout_partite e push_subscriptions.
  Non dicono che sono sicure, dicono che sono aperte per scelta: se una prende un gate
  nell'interfaccia, le policy devono seguirlo e questi casi vanno cambiati con loro.
- lettori-server.test.ts esegue leggiEventi() e leggiGiocatoriSquadra() sul database
  locale, mettendo le credenziali in process.env prima della prima chiamata perché
  supabaseAdmin nasce pigramente. Nessuna delle due controlla l'errore di PostgREST:
  una colonna rinominata darebbe zero righe, zero destinatari e nessuna push, senza
  che niente segnali il problema. Scrivendolo è emerso che eventi_app.note è NOT NULL,
  mentre RigaEvento lo dichiara nullable.

PROJECT_STATE: m12_niente_autovoto è applicata in produzione dal 06/09/2026,
verificata con `npx supabase migration list` (23 migration, local = remote).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:46:52 +02:00
davideandClaude Opus 5 f687322c3f Vieta l'autovoto, allinea la doc al codice e toglie tre riletture.
Rilettura completa della documentazione confrontata con il codice. Dove la doc
diceva il falso l'ho corretta; dove aveva ragione lei ho corretto il codice.

Autovoto (la doc aveva ragione)

- migration m12_niente_autovoto: vincoli mvp_no_autovoto e badge_social_no_autovoto,
  gli stessi che pagelle_voti ha dalla v1.0. Le righe che li violano vengono
  cancellate prima dell'ALTER, altrimenti fallisce; in locale non ce n'erano.
  M11 garantisce solo che il voto sia firmato con il proprio votante_id, non che il
  votato sia un altro: eleggersi MVP restava a un POST di distanza.
- VotazioneMvp non mostra più il votante nell'elenco, come già faceva VotoSocial.

Test che guardavano la colonna sbagliata

- scritture.test.ts verificava che aggiornato_il si muovesse, chiamandolo "quello che
  alimenta la serie di conferme". È l'opposto: la serie usa risposto_il, che il trigger
  di M9 deve tenere fermo. Ora il test prova a riscriverlo e controlla che il database
  abbia tenuto la prima risposta; prima passava anche senza trigger.
- destinatariSollecito() esce dalla route sollecita-presenze e diventa una funzione pura
  in presenze.ts, con i suoi test — stesso trattamento di avvisiPalloniEvento.

Tre riletture in meno

- giocatori-squadra, scout-store e avatar-store usavano invalidateQueries dove il dato
  scritto era già noto: ora setQueryData, come il resto dell'app. Resta scout-live, dove
  il lock può averlo vinto un altro dispositivo.

Documentazione riallineata

- presenze.md, badge.md, mvp.md: i limiti su RLS aperta e route non autenticata erano
  superati da M11 e DD-024;
- serie-presenze.md: il filtro è e.data < oggi, non <=, e l'evento di oggi non conta
  (conterebbe come assenza per tutti); aggiunta la tabella risposto_il/aggiornato_il;
- ARCHITECTURE.md ed EFFICIENZA_CLOUD.md: una sola eccezione a setQueryData;
- DATABASE.md: i vincoli delle tre tabelle di voto;
- PROJECT_STATE.md: fermo a M9, ora arriva a M12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:30:49 +02:00
davideandClaude Opus 5 752b300474 Copre presenze, calendario e guardia admin con nuovi test.
Confrontando le funzioni esportate di src/lib/ con i riferimenti in test/,
le uniche funzioni pure ancora scoperte erano i conteggi presenze, tre
funzioni del calendario, i titoli MVP e i rifiuti della guardia notifiche.

- unit: contaPresenzeGiocatore e totaliEventiGiocatore, compleanniEventi,
  convocatiEvento, eventoVuoto, mvpVintiPerGiocatore, e il nuovo
  auth-route.test.ts sui 401 di richiediAdmin (DD-024);
- integration/permessi: badge_social_voti, che mancava tra le tabelle di M11,
  e le deroghe "Gli admin gestiscono tutti/e ...", mai verificate finora.

Due modifiche al codice servivano per poter scrivere i test:

- presenze.ts: i conteggi leggevano la data da dataOggi() e non erano
  verificabili; ora accettano oggi come parametro opzionale, come già
  facevano serieConsecutiva e serieConferme. Toglie anche la dipendenza
  dall'orologio che avrebbe cambiato i risultati dei test delle serie dal
  10 settembre in poi;
- auth-route.server.ts: "Bearer .." passava il pre-check con tre segmenti
  vuoti e arrivava fino alla chiamata di rete; ora i segmenti devono essere
  non vuoti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:03:18 +02:00
Ivan CacciariandCursor 3d8cb22235 Compatta la classifica interna Squadra e allinea le tab a sottolineatura.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 18:50:11 +02:00
264 changed files with 24049 additions and 3976 deletions

No files matched your search

-1
View File
@@ -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 -1
View File
@@ -40,4 +40,14 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
supabase/.temp/
# Backup locali del database (contengono dati reali degli utenti):
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
ops/
@@ -1,38 +0,0 @@
# Obiettivi di squadra: sezione in Squadra + widget in home
## Stato attuale
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
## Cosa faccio
1. **Modello dati locale** in `src/lib/crapp-data.ts`
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
- Nessuna nuova rotta e nessuna modifica al bottom nav.
3. **Widget home dinamico** (`src/routes/index.tsx`)
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
4. **Obiettivi demo iniziali**
- 90% di presenze ad agosto (collegato agli eventi di agosto).
- 70% di risposte entro 24h nel prossimo mese.
- Prima vittoria del campionato (collegato allo storico match).
- 5 vittorie in campionato (collegato allo storico match).
- 10 vittorie in campionato (collegato allo storico match).
- 1 evento di squadra al mese (pizzata, ecc.).
## Cosa non cambia
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
- Bottom nav e rotte invariate.
@@ -1,58 +0,0 @@
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
## Stato attuale
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
## Cosa serve per renderla operativa
1. **Rosa e profili giocatori sul database**
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
- Collegare ogni riga all'utente autenticato corrispondente.
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
2. **Autenticazione reale**
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
3. **Calendario eventi persistente**
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
4. **Statistiche e badge dinamici**
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
5. **Scout live collegato ai dati reali**
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
6. **Classifica CSI**
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
7. **Notifiche push definitive**
- Verificare che i cron job inviino correttamente i promemoria palloni.
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
8. **Ruoli e permessi**
- Definire chi può creare/modificare eventi (capitano/admin).
- Chi può fare scout (designato per partita).
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
## Cosa resta salvato nel Cloud
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
## Decisioni da prendere insieme
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
@@ -1,25 +0,0 @@
# Ridimensionamento badge nella lista squadra
## Obiettivo
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
## Modifiche previste
1. **Lista squadra (`src/routes/squadra.tsx`)**
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
- Lasciare invariati avatar, nome, ruolo ed età.
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
3. **Verifica**
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
## Cosa non cambia
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
- Layout generale della pagina e bottom navigation.
@@ -1,14 +0,0 @@
# Rimuovere placeholder "Livello 7" dal Profilo
## Obiettivo
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
## Modifica
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
## Verifica
- Build senza errori.
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
@@ -1,34 +0,0 @@
# Selezione giocatore al primo avvio
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
## Cosa cambia
1. **Nuovo store `src/lib/user-store.ts`**
- Persiste in `localStorage` l'`id` del giocatore scelto.
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
2. **Nuova route `/benvenuto`**
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
- Non mostra la bottom navigation.
3. **Reindirizzamento condizionato in `__root.tsx`**
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
- Dopo la scelta, l'app funziona normalmente.
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
5. **Cambio utente dalle impostazioni**
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
## Note tecniche
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
- Nessun backend richiesto: resta un prototipo locale.
@@ -1,45 +0,0 @@
# Turno palloni nel calendario + promemoria
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
## Cosa vedrà la squadra
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
- **Due promemoria per l'incaricato**:
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
## Impostazione tecnica
**Backend (Lovable Cloud)**
- Attivazione di Lovable Cloud.
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
**Frontend**
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
**Notifiche push**
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
## Ordine di lavoro
1. Attivare Lovable Cloud e creare tabelle + policy.
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
3. Rotazione automatica suggerita + storico turni.
4. Banner promemoria in-app.
5. Notifiche push + job giornaliero.
-5
View File
@@ -1,5 +0,0 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-9e5645c506e5"
}
+19 -9
View File
@@ -5,23 +5,28 @@ su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ri
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
squadra e usare l'AI solo quando porta un beneficio reale. Il perché sta in
[docs/VISION.md](docs/VISION.md).
squadra e usare l'AI solo quando porta un beneficio reale. Deve restare semplice, veloce e
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.
@@ -30,6 +35,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
@@ -59,7 +65,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro.
Prima di dire che hai finito:
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
@@ -81,10 +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/TODO.md](docs/TODO.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
+31 -128
View File
@@ -1,138 +1,41 @@
# Project State
Ultimo aggiornamento: 04/09/2026
Ultimo aggiornamento: 01/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.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
(3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
applicate in produzione (verificato con `npx supabase migration list` il 01/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 `develop`, non ancora su `main`.
- **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).
## 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`
- 20 migration in `supabase/migrations/`, fino a `m9_risposte_presenze_risposto_il`
- 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 M9 applicate al nuovo Supabase
- `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 `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
---
## Moduli completati
- Squadra
- Presenze
- Badge
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
- Pagelle
- MVP
- Notifiche
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
- Serie di presenze (specifica in `docs/modules/serie-presenze.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: la v1.1 è completa, tesseramento CSI incluso (numero e data di tessera
registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md).
---
- 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» è impostato come allenatore in produzione (24/09/2026). Numero 99
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
presenti.
## 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.
+4 -6
View File
@@ -34,6 +34,7 @@ npm run dev # http://localhost:8080
```bash
npm run build # build di produzione
npm run lint # eslint (include prettier)
npm run typecheck # controllo dei tipi TypeScript
npm run test # test unit; npm run test:all per la suite completa
```
@@ -48,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
+132
View File
@@ -0,0 +1,132 @@
# Backup di disaster recovery
Script per scaricare su questo host un backup completo del progetto Supabase:
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
se il progetto Supabase (o Vercel) venisse perso.
## Preparazione, passo per passo
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
`./backups/backup.sh`).
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
```bash
npx supabase --version
```
2. **Accedi con il tuo account Supabase**:
```bash
npx supabase login
```
Si apre il browser per l'OAuth; il token resta salvato in locale.
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
`supabase/config.toml`, quindi in genere basta):
```bash
npx supabase link --project-ref <project_id>
```
Verifica che sia andato a buon fine con `npx supabase projects list`
(il progetto deve comparire con `"linked":true`).
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
```
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
```
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
Project Settings → API. La **service role key** è quella con permessi pieni
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
esposta lato client né committata.
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
```bash
gpg --version
```
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
## Uso
```bash
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase a te scelta) e cancella cartella+tar.gz
# in chiaro
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
# (nessuna passphrase richiesta)
```
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
mano quando non servono più.
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
incompleto non va considerato valido, rilancialo.
Il backup contiene:
| File/cartella | Contenuto |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
| `data_public.sql` | dati delle tabelle applicative |
| `schema_auth.sql` | schema utenti Supabase Auth |
| `data_auth.sql` | utenti reali, incluso l'hash della password |
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
## Decomprimere
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
del backup:
```bash
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
```
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
Archivio non cifrato (generato con `--no-encrypt`):
```bash
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
```
## Ripristino
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
contenuto di `storage/`.
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
backup — vedi sotto).
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
GitHub e Gitea).
## Cosa NON è incluso
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
- **Codice applicativo**: vive nel repo git, non nel database.
## Sicurezza
Questi backup contengono password hash, documenti d'identità e altri dati
personali reali. Per questo:
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
quelli sì restano in git) — **non committare mai i dati generati**;
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
invece del tar.gz in chiaro generato con `--no-encrypt`;
- cancella i backup in chiaro quando non ti servono più.
## File di questa cartella
| File | Ruolo |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
# Uso:
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
#
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
# installato (esegue download-storage.mjs, che importa un modulo .ts).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAMP="$(date +%Y%m%d_%H%M)"
RUN_DIR="$SCRIPT_DIR/$STAMP"
mkdir -p "$RUN_DIR"
echo "==> Backup in $RUN_DIR"
echo "==> Dump schema pubblico"
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
echo "==> Dump dati pubblici"
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
echo "==> Dump schema auth"
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
echo "==> Dump dati auth (utenti)"
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
echo "==> Download file storage (bucket)"
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
echo "==> Backup completato: $RUN_DIR"
TARBALL="$RUN_DIR.tar.gz"
echo "==> Comprimo in $TARBALL"
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
if [[ "${1:-}" == "--no-encrypt" ]]; then
echo "==> Archivio pronto: $TARBALL"
else
ARCHIVE="$TARBALL.gpg"
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
rm -rf "$RUN_DIR" "$TARBALL"
echo "==> Archivio cifrato pronto: $ARCHIVE"
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
fi
+45
View File
@@ -0,0 +1,45 @@
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
// Uso: node backups/download-storage.mjs [cartella-output]
import { createClient } from "@supabase/supabase-js";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { elencaFile, leggiEnv } from "./storage-core.ts";
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const elencaCartella = (bucket) => async (prefisso) => {
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
if (error) throw error;
return data;
};
const falliti = [];
for (const bucket of BUCKETS) {
console.log(`Bucket: ${bucket}`);
const files = await elencaFile(elencaCartella(bucket));
console.log(` ${files.length} file trovati`);
for (const path of files) {
const { data, error } = await supabase.storage.from(bucket).download(path);
if (error) {
console.error(` ERRORE ${path}:`, error.message);
falliti.push(`${bucket}/${path}`);
continue;
}
const destPath = join(OUT_DIR, bucket, path);
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
console.log(` scaricato: ${bucket}/${path}`);
}
}
if (falliti.length) {
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
process.exit(1);
}
console.log("Fatto.");
+30
View File
@@ -0,0 +1,30 @@
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
export function leggiEnv(testo: string): Record<string, string> {
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
if (!match) continue;
const [, chiave, grezzo] = match as unknown as [string, string, string];
const tra = /^(["'])(.*)\1$/.exec(grezzo);
env[chiave] = tra ? tra[2]! : grezzo;
}
return env;
}
export type Voce = { name: string; id: string | null };
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
export async function elencaFile(
lista: (prefisso: string) => Promise<Voce[]>,
prefisso = "",
): Promise<string[]> {
const file: string[] = [];
for (const voce of await lista(prefisso)) {
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
else file.push(percorso);
}
return file;
}
+5 -16
View File
@@ -3,9 +3,8 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "tanstack_start_ts",
"name": "crapp",
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -27,7 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
@@ -130,14 +129,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
@@ -424,7 +415,7 @@
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -800,10 +791,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -820,6 +807,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
+1 -1
View File
@@ -4,4 +4,4 @@ saveTextLockfile = true
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
minimumReleaseAgeExcludes = []
+41 -2
View File
@@ -60,8 +60,9 @@ ricorrente:
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
eccezione oggi: `scout-live.ts`);
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
eccezione: `scout-live.ts`, dove il lock può essere stato preso da un altro dispositivo,
quindi quello che abbiamo scritto non è detto sia quello che vale;
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
@@ -101,12 +102,50 @@ 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
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
+281 -174
View File
@@ -1,209 +1,316 @@
# Changelog
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
qui: sta in [ROADMAP.md](ROADMAP.md).
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
## Versione attuale — agosto 2026
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
versioni `0.y.z` erano pre-release.
### Le notifiche push arrivano anche ad app chiusa
## [Non rilasciato]
- Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291)
invece di essere recuperato dal service worker con una fetch al risveglio: era quella fetch
a non arrivare mai in tempo a telefono bloccato, e la notifica non compariva affatto
(DD-026).
- Spariscono la route `/api/public/push-messaggio`, la coda `promemoria_push` e
`messaggioPalloniOggi()`: esistevano solo per rimediare al payload vuoto.
## [1.2.3] - 2026-10-04
### Lo Scout Live si apre dalla pagina della partita
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration.
- Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo
l'URL. Ora la card `ScoutEntry` sta in `/partita/$id`, sezione «Scout live».
- Si accende solo se la partita aperta è quella di oggi (nuova prop `eventoId`), altrimenti
resta grigia con «Si attiva il giorno della partita». Lock di sessione invariato.
- Non è più riservato agli admin: può scoutare chiunque sia autenticato, uno per volta grazie
al lock. Sparisce il messaggio «Scout riservato».
### Modificato
### Il sondaggio pre-partita apre alle 8:00 del giorno della partita
- **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).
- Prima era sempre votabile, anche settimane prima: ora la card resta chiusa con l'avviso di
apertura e si sblocca alle 8:00 del giorno stesso.
- Quando è aperto, gli amministratori hanno nella card il pulsante «Avvisa tutti del
sondaggio» (`POST /api/public/apri-sondaggio`), che manda la push a tutti i dispositivi
iscritti — stesso meccanismo del sollecito presenze. Nessun cron: l'invio è manuale.
## [1.2.2] - 2026-10-01
### Le note dell'evento si leggono aprendolo
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.
- Il campo Note del form eventi si poteva scrivere ma non lo vedeva nessuno: ora compare
nella scheda di `/allenamento/$id` e `/partita/$id`, sotto orario e luogo, con gli a capo
mantenuti. Se è vuoto non compare niente.
### Rimosso
### Form eventi: data e ora non sfondano più la card su iOS
- **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.
- Lo stesso difetto già corretto sul tesseramento: `Campo` e le classi degli input erano
ricopiati identici in tre file, quindi il `min-w-0` aggiunto in `ProfiloAmministrativo`
non arrivava né al form «Nuovo evento» né alla dashboard admin. Ora `Campo` e
`classiInput` stanno una volta sola in `ui-bits`.
- La regola CSS che rende ridimensionabili i controlli nativi copre anche
`input[type="time"]`, che nel form eventi sta affiancato alla data in `grid-cols-2`.
- **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).
### Profilo: tab Documenti e Opzioni, barra senza scroll
### Modificato
- L'etichetta nominava lo scopo (il tesseramento CSI) invece del contenuto: dentro ci sono
dati personali, documento, certificato medico e foto tessera. Cambia anche la rotta
(`/profilo?tab=documenti`): i vecchi link `?tab=tesseramento` aprono la tab Stagione.
- «Impostazioni» diventa «Opzioni»: con le quattro etichette accorciate la barra delle
sottosezioni ci sta in uno schermo da telefono. Le voci ora si dividono la riga in parti
uguali (`grow basis-0`) e tornano a scorrere solo se non ci stanno, quindi vale anche per
le barre di Squadra e Classifica.
- **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).
### Palloni: allenamenti senza proposta automatica
- **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).
- `completaTurni` non assegna più gli allenamenti: restano «da assegnare» finché non si
sceglie a mano. Le partite tengono la rotazione. Migration M10 cancella eventuali turni
salvati su allenamenti da oggi in poi.
- **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).
### Profilo: etichetta notifiche allineata al comportamento
## [1.2.1] - 2026-09-30
- L’interruttore in Impostazioni non è più «Notifiche turno palloni»: iscrive il dispositivo
a tutte le push (palloni, solleciti) e alle smart in app. Testo e docs aggiornati.
- Il widget Home «Completa il tuo profilo» apre direttamente la tab Tesseramento
(`/profilo?tab=tesseramento`).
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.
### Revisione dell'interfaccia: accessibilità, movimento, peso
### Aggiunto
- **Contrasto**: `--success`, `--info` e `--training` erano tra 3.3:1 e 3.5:1 con il testo
bianco sopra (chip «Presente», «Allenamento», celle del calendario): ora sono sotto la
soglia di luminosità che garantisce 4.5:1. I gradi dei badge usavano `text-oro` e
`text-argento` su bianco, cioè 1.9:1 e 2.5:1 — praticamente invisibili: nascono i token
`--oro-testo`, `--argento-testo`, `--bronzo-testo` per il testo, mentre le versioni chiare
restano su sfondi e bordi.
- **PWA**: mancava `viewport-fit=cover`, quindi `env(safe-area-inset-bottom)` valeva sempre 0
e su iPhone la BottomNav finiva sotto la home bar. `theme-color` e `background_color` erano
`#111111` su un'app chiara: barra di stato nera e splash nero prima di una UI bianca.
- **`lang="it"`** al posto di `lang="en"`, su un'app interamente in italiano; 404 e schermata
d'errore tradotte; anteprima social ripulita dall'immagine Lovable scaduta e da
`twitter:site` che puntava a `@Lovable`.
- **Tocco e tastiera**: nessun `:focus-visible` era definito (ora c'è una regola globale);
chip presenza, filtri e bottoni icona portati a 44px; `aria-current` sulla navigazione,
`aria-pressed` sui controlli a stato, `aria-controls` sulle sezioni a tendina, `aria-busy`
sui caricamenti. Il testo sotto i 12px è sparito (108 occorrenze).
- **Movimento** (DD-021): molle interrompibili di `motion` al posto delle `@keyframes` a
durata fissa. `Reveal` compare quando entra davvero nel viewport (prima consumava
l'animazione a vuoto sotto la piega); `Barra` anima `scaleX` invece di `width`; `Numero`
cambia rotta se il dato cambia a metà conteggio. Il calendario si cambia mese anche con lo
swipe, con il punto d'arrivo scelto proiettando la velocità di rilascio.
- **Meno dipendenze** (DD-021): rimossi 43 componenti `src/components/ui/` non importati da
nessuna parte e ~45 dipendenze (tutti i `@radix-ui/*`, `recharts`, `react-hook-form`,
`date-fns`, `embla`, `cmdk`; `zod` resta perché lo usano le route API). Restano `drawer` e
`sonner`. Il bundle **non** cala per questo — quel codice era già escluso dal
tree-shaking — ma cala la superficie da aggiornare e da controllare: 50 dipendenze dirette
diventano 21. Il bundle client cresce di ~42 KB gzip per `motion` (267 → 308 KB).
- **Primitiva `Card`**: `rounded-3xl bg-card p-4 shadow-card` era ricopiato a mano 22 volte.
- **Tema scuro rimosso** (DD-022): esisteva un blocco `.dark` mai applicato e incoerente.
- Tolte le quattro switch di notifica in `/profilo` che erano `defaultChecked` e non facevano
niente, e la conferma nativa prima di _cambiare_ la foto profilo (resta su quella che la
rimuove, che è irreversibile).
- Le celle del calendario con più tipi di evento non usano più un gradiente a fette con
un'ombra bianca sul numero per restare leggibili: fondo neutro e un puntino per tipo.
- **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`.
### Serie di presenze calcolate sui dati reali
### Modificato
- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e
`risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze»
del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra»
che ne dipendono.
- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante
della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo
l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento).
Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge
"Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui
in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)).
- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il
successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%,
poi 3/6 = 50%).
- **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.
### Segnalazioni dal profilo
### Corretto
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo` → Impostazioni: due
link che aprono una issue GitHub sul template giusto
(`.github/ISSUE_TEMPLATE/bug_report.yml`, `feature_request.yml`). Nessuna tabella e nessuna
schermata di gestione: la segnalazione vive su GitHub
(vedi [modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
### Autenticazione e dashboard amministratore (in produzione)
## [1.2.0] - 2026-09-29
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
giocatore stesso (DD-016 regola 2).
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
per amministrare.
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
Applicata in produzione il 03/09/2026, dopo aver impostato l'email di tutta la rosa
attiva — il login era già l'unica via d'accesso lato app, quindi il collegamento dei
singoli account (che resta un processo continuo a ogni login) non era comunque
condizionato da questa migration.
- Collegamento automatico al proprio giocatore per email (DD-018, migration
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
riprovare con un altro account.
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni
giocatore già in rosa.
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
documento, certificato medico e foto tessera con le relative scadenze
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
certificato e foto tessera, export CSV per il tesseramento CSI.
- Migration `m2_profili_giocatore` (tabella dei profili e bucket privato), additiva.
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
compila i dati personali al posto di un giocatore e scollega un account da un profilo
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
policy di M1 e M2 lo consentivano già.
- Foto profilo sincronizzate tra dispositivi: da `/profilo` la foto caricata finisce nel
bucket pubblico `avatar-giocatori` (migration `m6_avatar_giocatori`) invece che in
`localStorage`, così compare per tutta la squadra e non solo su chi l'ha caricata.
L'Avatar mostra numero/iniziali finché la foto non è presente.
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" ora passa dalla
tabella `scout_sessioni` invece che da `localStorage`, quindi due telefoni non possono più
prendere il controllo insieme sovrascrivendosi a vicenda. Le partite scoutate concluse
vengono archiviate nella nuova tabella `scout_partite` (migration `m7_scout_partite`):
prima restavano visibili solo sul telefono di chi aveva chiuso la partita.
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
Le migration `m22`–`m25` sono già in produzione dal 29/09/2026 e il worker delle email è in funzione;
questa versione porta il codice dell'app che le usa.
### Test
### Aggiunto
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
senza nuove dipendenze: `npm run test` e `npm run test:all`.
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
illeggibile restava bloccata per sempre invece di scadere.
- **Notifiche email** — ogni notifica del centro notifiche in-app (messaggi dello staff, promemoria
a 24 e 3 ore, turno palloni, sollecito presenze, sondaggio pre-partita) arriva anche per email
all'indirizzo Gmail con cui il giocatore si è registrato, gratis e senza aprire porte sul
router: un worker Docker (`mailer/`) sull'host di casa legge una coda su Supabase (migration
`m22_notifiche_email`) e invia tramite un account Gmail dedicato, con nuovi tentativi in caso di
errore e un tetto giornaliero sotto il limite di Gmail. In Profilo → Opzioni c'è l'interruttore
«Email», acceso di default, per account (tabella `preferenze_utente`; DD-036).
- **Push dei promemoria** — i promemoria a 24 e 3 ore, che arrivavano solo in-app e per email,
partono ora anche come push a tutti i dispositivi iscritti del giocatore. Nascono nel database,
che non può firmare una push, quindi la manda lo stesso worker con lo stesso modulo dell'app
(migration `m24_push_promemoria_e_sondaggio`, DD-038). Le iscrizioni scadute vengono eliminate e
una push rimasta in coda oltre 3 ore si scarta. Il worker va ricostruito e configurato con le chiavi
VAPID dell'app (vedi [WORKER_EMAIL.md](WORKER_EMAIL.md)); senza, manda solo le email.
- **Sondaggio pre-partita nel centro notifiche e per email** — prima era solo una push. Ora compare
anche tra le notifiche in-app e arriva per email ai giocatori attivi, allenatori esclusi (DD-038).
- **Documentazione e test** — nuovo [WORKER_EMAIL.md](WORKER_EMAIL.md) (installazione, gestione,
diagnosi, prova in locale, spostamento su un altro host); catalogo delle notifiche in
[notifiche.md](modules/notifiche.md), con cosa le fa partire, a chi arrivano e su quali canali;
tabella delle notifiche dell'allenatore in [allenatore.md](modules/allenatore.md); decisioni
DD-036, DD-037, DD-038 e DD-039. Nuovi test di integrazione per la coda delle email, il registro
dei promemoria, la coda push, il sondaggio e i destinatari per ruolo (promemoria, messaggio dello
staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), più i test unitari del
worker.
### Collegamento CSI
### Modificato
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
- La pagina Campionato non usa più dati dimostrativi.
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
- **L'allenatore non riceve più i promemoria degli eventi** (24 e 3 ore prima, DD-039): per lui non
c'è nulla da confermare, dato che non risponde alle presenze e non è convocabile, e per una cena di
squadra il promemoria non aveva senso. Riceve ancora i messaggi dello staff. Migration
`m25_promemoria_solo_giocatori`.
- **Messaggio del pulsante «Sondaggio»** — se nessun dispositivo ha le notifiche attive dice che
l'avviso è stato inviato in app e per email, invece di «Nessun dispositivo con le notifiche
attive».
- **`ops/` fuori da git** — il playbook Ansible locale per il backup cifrato non è più tracciato.
### Infrastruttura
### Corretto
- Migrazione completa da Lovable a sviluppo locale.
- Configurazione Git.
- Repository GitHub indipendente.
- Deploy automatico tramite Vercel.
- Branch main e develop.
- **I promemoria dell'evento non ricompaiono più se li elimini** — il promemoria a 24 e a 3 ore
eliminato dal centro notifiche veniva rigenerato dal job automatico al giro successivo, come non
letto e, con le email, con una nuova mail a ogni giro fino all'inizio dell'evento. Ora il job
ricorda ciò che ha già generato (migration `m23_promemoria_gia_generati`, DD-037). Il difetto
esisteva dal centro notifiche (`m17`) e si è notato con l'arrivo delle email.
## Versione 1.0 — luglio 2026
## [1.1.1] - 2026-09-28
Prima versione usata dalla squadra. Funzionalità incluse: vedi
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
### Aggiunto
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
pausa dopo 7 giorni senza richieste API.
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
pertinente.
### Modificato
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
di decompressione nel README.
### Corretto
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
mostrare subito tutto.
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
il cambio tab verso Obiettivi.
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
sono allenamenti o partite.
## [1.1.0] - 2026-09-27
### Aggiunto
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
nessuna push (DD-035).
## [1.0.0] - 2026-09-24
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
### Aggiunto
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
`m17_notifiche_utente` (DD-030).
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
notifiche attive.
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
di un evento esistente compare una conferma, come per l'eliminazione.
- **Backup completo** — uno script scarica schema e dati del database e i file dello
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
### Modificato
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
mensile, che si scorre anche con lo swipe come quello della squadra.
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
### Corretto
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
## [0.9.2] - 2026-09-10
### Aggiunto
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
le notifiche push attive e chi sono.
### Modificato
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
alla volta.
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
su chi vede quei dati).
### Rimosso
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
verso l'editor): l'app non ci gira più.
## [0.9.1] - 2026-09-10
### Modificato
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
## [0.9.0] - 2026-09-09
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
questa release riunisce tutto ciò che l'app fa oggi in produzione.
### Aggiunto
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
certificati resta un'estensione futura.
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
squadra al posto di chat e fogli condivisi.
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
reali della rosa.
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
durante la partita.
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
votati tra compagni.
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
compagni, un voto a testa.
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
convocazioni, pagelle e risultati di campionato.
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
allenamenti e partite.
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
dati della rosa, con download CSV.
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
manuale da parte degli amministratori.
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
presenza già registrato per le convocazioni.
### Sicurezza
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
+128 -48
View File
@@ -7,79 +7,159 @@ non in questo file.
## Permessi di scrittura
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). 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).
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, con le eccezioni della
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
| `user_roles` | solo admin |
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `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) |
| `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) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## 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, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra: 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` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include 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. |
| `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. 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: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `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. | |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
| `badge_social_voti` | Voti social per i badge. | |
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. |
| 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 `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 ma non quelli dei pulsanti.
### 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) |
| `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
-972
View File
@@ -1,972 +0,0 @@
# Registro delle decisioni di progetto
Questo documento raccoglie le **decisioni importanti** prese nel corso della vita di CrAPP: scelte che hanno influito sulla direzione del prodotto, sull’organizzazione del lavoro o su come l’app si evolve nel tempo.
Non descrive _come_ è fatto il codice. Per quello esistono `ARCHITECTURE.md` e `DATABASE.md`.
Serve a rispondere a domande del tipo:
- _Perché abbiamo scelto così?_
- _Cosa avevamo escluso e perché?_
- _Quando conviene riaprire una decisione?_
---
## Indice
**Accettate**
| ID | Titolo |
| --------------------------------------------------------------------------------- | ------------------------------------- |
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi |
| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion |
| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara |
| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo |
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
**In valutazione**
| ID | Titolo |
| ---------------------------------------------------------------- | --------------------- |
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
**Sostituite**
| ID | Titolo |
| ---------------------------------------------------------------- | --------------------- |
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
---
## Come usare questo registro
Ogni decisione segue lo stesso schema:
| 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 fondo al documento, assegnare il primo ID
libero e aggiungerlo all'indice.
---
## Decisioni accettate
---
### 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.
---
### 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 v1.0 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.
---
### DD-003 — Due branch: main stabile, develop per il lavoro
**Data:** agosto 2026
**Stato:** Sostituita da [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) (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.
---
### DD-004 — Ogni versione aggiunge, non riscrive
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
CrAPP v1.0 è 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.
---
### 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).
---
### 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” è in roadmap v1.2, non v1.1.
- 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).
---
### 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.
---
### 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.
---
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
**Data:** agosto 2026
**Stato:** Accettata
**Contesto**
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
**Decisione**
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
**Alternative scartate**
- Integrazione CSI già in v1.1 → 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 della v1.1.
**Riesame**
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
---
### DD-010 — Profilo giocatore: niente storico certificati in v1
**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**
In v1 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.
---
### 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 (v1.1), 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).
---
### DD-012 — Non migrare gli ID giocatore in v1.1
**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 la v1.1 **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 tutto a UUID in v1.1 → 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 la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
---
### 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.
---
### DD-016 — Schema dati Profilo Giocatore v1.1 (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 v1.0 già operative.
**Decisione**
Per la v1.1 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, foto tessera) 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 v1.0 esistenti 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 in v1.1 (coerente con DD-012).
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1.
**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 tabelle v1.0 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 in v1 (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, post v1.1).
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
---
### 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, foto): 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.
---
### 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`.
---
## Decisioni in valutazione
---
### 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**
Post v1.1, con migration e test dedicati.
---
### 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.
---
### DD-019 — Il branch dei commit lo decide l'utente
**Data:** 4 settembre 2026
**Stato:** Accettata — sostituisce [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro)
**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.
---
### 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.
---
### 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).
---
### 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.
### DD-023 — Ogni scrittura è limitata a chi la fa
**Data:** 6 settembre 2026
**Stato:** Accettata
**Contesto**
Le tabelle della v1.0 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.
### 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.
### 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 v1.0.6).
**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.
---
### 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).
+4 -3
View File
@@ -11,14 +11,15 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi:
`src/lib/scout-live.ts`.
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
`src/lib/scout-live.ts`, dove il lock di sessione può essere stato preso da un altro
dispositivo e la riga scritta non basta a sapere chi ha vinto.
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
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.
+29
View File
@@ -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, partite, conferme entro 24 ore). 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/`. |
+106
View File
@@ -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.
+19 -10
View File
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
@@ -28,6 +28,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
## Backup e disaster recovery
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
Uso, contenuto del backup e procedura di ripristino sono descritti in
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
variabili d'ambiente Vercel (richiede login interattivo).
## Migrazione su server proprio (sintesi)
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
+26 -21
View File
@@ -2,43 +2,48 @@
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 |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [TODO.md](TODO.md) | A cosa si sta lavorando adesso |
| [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 → `VISION.md` → `ROADMAP.md` →
`ARCHITECTURE.md` → `DATABASE.md` → `DESIGN_DECISIONS.md` → `TODO.md` → il documento del
modulo interessato in `modules/`.
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
→ `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
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco;
- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco: segue
il formato Keep a Changelog, con il lavoro non ancora rilasciato sotto `[Non rilasciato]`
e le voci divise per categoria (Aggiunto, Modificato, Sicurezza…);
- 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.
+34 -22
View File
@@ -1,10 +1,12 @@
# Roadmap
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata, `TODO.md` cosa si
sta facendo adesso.
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con quale versione,
[PROJECT_STATE.md](../PROJECT_STATE.md) cosa si sta facendo adesso.
## Versione 1.0 — rilasciata
## Fatto
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
@@ -14,35 +16,45 @@ sta facendo adesso.
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Votazione MVP
- [x] Obiettivi di squadra
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
## Versione 1.1
Le voci spuntate sono in produzione su `main`. I passaggi di attivazione ancora aperti (per
esempio il collegamento dei singoli account) stanno in [PROJECT_STATE.md](../PROJECT_STATE.md).
- [x] Certificati medici — caricamento, scadenza, stato e download; 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] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del
profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
sollecito presenze)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(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] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, 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)
## Versione 1.2
## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
## Versione 2.0
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Risultati campionato
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
## Idee future
- [ ] Gestione quote
-40
View File
@@ -1,40 +0,0 @@
# TODO
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
## In corso
- Correzione push Android pronta in locale: aggiornamento del worker nelle sessioni della
webapp e test del canale cifrato verificati. Resta la verifica su Motorola installato,
ad app chiusa e schermo bloccato; procedura e limiti in [Notifiche](modules/notifiche.md).
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
tabelle v1.0, è stata applicata in produzione (03/09/2026). Resta il collegamento dei
singoli account: ogni giocatore si aggancia al proprio profilo al primo login (DD-018), un
processo continuo — vale anche per chi viene aggiunto a stagione in corso da `/admin`.
Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
## Prossimo
- Niente di assegnato. Le voci ancora aperte in [ROADMAP.md](ROADMAP.md) sono «Calendario
ufficiale» (v2.0, i dati delle gare future arrivano già dal feed CSI) e la v1.2.
La gestione tesseramenti CSI della v1.1 è completa: raccolta dati, export CSV e tracciamento
di chi è già tesserato (numero e data di tessera, migration `m8_tesseramento_csi`, registrabili
da `/admin`).
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
la dashboard amministratore legge quei dati.
## Manutenzione ricorrente
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
## Backlog
- AI Allenamenti (roadmap v1.2).
- Backup automatici (roadmap, idee future).
-32
View File
@@ -1,32 +0,0 @@
# Visione del progetto
## Missione
CrAPP nasce con un obiettivo semplice:
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
## Principi del progetto
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
- Ridurre il lavoro amministrativo.
- Migliorare il coinvolgimento della squadra.
- Centralizzare tutte le informazioni in un'unica piattaforma.
- Automatizzare le attività ripetitive.
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
## Filosofia
CrAPP deve essere:
- Semplice
- Veloce
- Divertente
- Intuitiva
- Accessibile da smartphone
- Utilizzabile anche da persone poco esperte
## Obiettivo finale
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
+246
View File
@@ -0,0 +1,246 @@
# 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 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](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,
su un Raspberry Pi con Docker. La push dei promemoria (`m24`) richiede le chiavi VAPID nel `.env` del
worker e la ricostruzione del container.
## Cosa serve
| Requisito | Note |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| Un host con Docker | Sempre acceso e con la porta 587 in uscita libera. Nessuna porta in entrata, nessun IP pubblico. |
| Account Gmail dedicato | Con verifica in due passaggi e una password per app (vedi sotto). |
| Chiave del progetto | La service role di Supabase, oggi nel `.env` della root del repo come `SUPABASE_SERVICE_ROLE_KEY`. |
| Indirizzo dell'app | L'https pubblico a cui puntano i link nelle mail (`APP_URL`). |
| Chiavi VAPID | Le stesse `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` dell'app su Vercel: per la push dei promemoria. |
Il container usa l'immagine `oven/bun:1-alpine`, disponibile anche per ARM (Raspberry Pi). Si
costruisce dalla **radice del repository** (`docker-compose.yml` imposta `context: ..`), perché include
anche `src/lib/webpush.server.ts`, lo stesso modulo push dell'app: `docker compose up -d --build`
va comunque lanciato dentro `mailer/`. `mailer/Dockerfile.dockerignore` ammette nel contesto solo i
file usati, quindi nemmeno il `.env` finisce nell'immagine.
## Account Gmail e password per app
Il worker non usa la password dell'account: Google la rifiuterebbe. Serve una **password per app**.
1. Meglio un account **dedicato** e non quello personale: i messaggi partono da quell'indirizzo.
2. Attiva la **verifica in due passaggi** (myaccount.google.com → Sicurezza e accesso).
3. Apri direttamente **https://myaccount.google.com/apppasswords**: la voce non compare nei menu.
4. Dai un nome (per esempio «CrAPP mailer») e crea. Google mostra **una volta sola** una password di
16 caratteri, a gruppi di quattro: copiala.
5. Nel `.env` va scritta **senza spazi** (16 caratteri attaccati): con gli spazi il file, letto come
script di shell, si rompe. Non va mai incollata in chat né nel repository.
Si può revocare in ogni momento dalla stessa pagina, senza cambiare la password dell'account.
**Se la pagina dice «L'impostazione che stai cercando non è disponibile per il tuo account»:**
| Causa | Cosa fare |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Verifica in due passaggi non attiva, o solo passkey come secondo passaggio | Attiva la verifica e aggiungi anche un'**app di autenticazione** (myaccount.google.com/signinoptions/twosv). |
| Protezione avanzata attiva | Disattivala da myaccount.google.com/advanced-protection. |
| Account Workspace con l'impostazione disattivata dall'amministratore | Usa un account `@gmail.com` normale. |
Se nulla funziona, l'alternativa è un altro account Gmail creato da zero, oppure un relay con un
dominio proprio (vedi DD-036).
## Installazione
1. **Migration in produzione.** `npx supabase db push` applica `m22`. È additiva: non modifica nulla
di esistente e non invia mail finché il worker non parte. Dal momento in cui è applicata, le
nuove notifiche si accodano: **avvia il worker subito**, nella stessa sessione. Le notifiche
accodate e non ancora inviate partirebbero tutte insieme al primo avvio, anche se ormai vecchie
(il 29/09/2026 sono partiti così due messaggi di prova dell'admin).
2. **Configura il worker.** In `mailer/`: `cp .env.example .env`, `chmod 600 .env` e compila:
| Variabile | Default | Contenuto |
| --------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
| `SUPABASE_URL` | — | URL del progetto |
| `SUPABASE_SERVICE_ROLE_KEY` | — | chiave service role: salta la RLS, va trattata come una password |
| `SMTP_USER`, `SMTP_PASS` | — | account Gmail dedicato e password per app (vanno insieme) |
| `APP_URL` | — | base dei link nelle mail, in https e senza slash finale |
| `MAIL_FROM`, `MAIL_FROM_NAME` | `SMTP_USER`, `CrAPP` | indirizzo e nome del mittente |
| `POLL_SECONDI` | 30 | intervallo di polling |
| `MAIL_LIMITE_GIORNO` | 400 | tetto delle ultime 24 ore, sotto i circa 500 di Gmail |
| `MAIL_LOTTO` | 10 | mail prese a ogni giro |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | `smtp.gmail.com`, 587, `false` | server SMTP (`true` solo per la porta 465) |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | — | chiavi per la push dei promemoria (vanno insieme; senza, solo email) |
| `VAPID_SUBJECT` | `mailto:crapp@crapvolley.it` | contatto nell'intestazione VAPID, come nell'app |
Le chiavi VAPID devono essere **le stesse dell'app**: le iscrizioni push dei dispositivi sono
legate alla chiave pubblica con cui sono nate, e con chiavi diverse i servizi push rifiutano l'invio
(403). Se mancano il worker parte lo stesso e nel log compare «push dei promemoria: DISATTIVATA».
`SMTP_USER` e `SMTP_PASS` vanno impostati insieme o nessuno dei due (senza credenziali solo per un
server di prova locale). Un numero non valido o una variabile obbligatoria mancante fa uscire il
worker con l'elenco degli errori. Il `.env` è ignorato da git.
**`APP_URL`:** usa un indirizzo stabile, cioè il dominio di produzione o l'alias del branch
(`…-git-<branch>-….vercel.app`). L'indirizzo di un singolo deploy (`…-<codice>-….vercel.app`)
cambia a ogni pubblicazione e mostra il codice di quel momento. Se il progetto Vercel ha la
Deployment Protection attiva, chi apre il link di un'anteprima vede la richiesta di accesso a
Vercel.
3. **Avvia:** `docker compose up -d --build` dentro `mailer/`. Il container ha
`restart: unless-stopped`, quindi riparte da solo dopo un riavvio dell'host.
4. **Verifica:** `docker compose logs` deve mostrare «SMTP raggiungibile e credenziali accettate» e
«push dei promemoria: attiva»;
dopo circa 30 secondi `docker ps` deve mostrare `healthy`. Poi manda un messaggio a un giocatore
dalla dashboard admin e controlla che la riga in coda passi a `inviata` e la mail arrivi.
## Gestione
| Cosa | Comando (dentro `mailer/`) |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Vedere il log in diretta | `docker compose logs -f` |
| Ultime righe del log | `docker compose logs --tail 50` |
| Stato e salute | `docker ps` (colonna STATUS: `healthy` / `unhealthy`) |
| Dopo aver cambiato `.env` | `docker compose up -d --force-recreate` (il `.env` è riletto a ogni avvio; non serve ricostruire l'immagine) |
| Dopo aver cambiato il codice | `git pull`, poi `docker compose up -d --build` |
| Fermarlo | `docker compose down` (le mail restano in coda e partono alla ripartenza) |
| Spegnere subito l'accodamento | `DROP TRIGGER notifiche_utente_accoda_email ON public.notifiche_utente;` da SQL: le notifiche tornano come prima di M22. Per riattivarlo si ricrea il trigger come in `m22`. |
Il log riporta solo id notifica, tipo ed esito (`inviata`, `da riprovare tra N min`, `fallita`,
`ciclo fermato`), mai indirizzi né testi.
## Vedere cosa è partito
- **Posta inviata dell'account Gmail:** le mail complete, come le ha ricevute il destinatario. È il
riscontro più sicuro.
- **SQL Editor di Supabase**, per stato e contenuto:
```sql
select c.stato, c.inviata_il, c.tentativi, c.errore,
g.nome, g.cognome, n.tipo, n.titolo, n.corpo
from notifiche_email_coda c
join notifiche_utente n on n.id = c.notifica_id
join giocatori_squadra g on g.id = n.giocatore_id
order by n.creato_il desc
limit 50;
```
Per un riepilogo: `select stato, count(*) from notifiche_email_coda group by stato;`. Lo stesso per le push dei
promemoria, con la tabella `notifiche_push_coda` (colonne `stato`, `tentativi`, `errore`, `inviata_il`).
Il testo del messaggio sta in `titolo` (oggetto) e `corpo`.
## Spostare il worker su un altro host
Il container non conserva nulla: la coda, gli esiti e il conteggio delle ultime 24 ore stanno in
Supabase. Cambiare host non fa perdere né duplicare mail.
1. **Sul nuovo host** servono Docker con il plugin Compose e la porta 587 in uscita libera.
L'immagine `oven/bun` esiste sia per ARM sia per x86, quindi l'architettura può cambiare.
2. **Porta il codice:** `git clone` del repository (o `git pull` se c'è già) e usa la cartella
`mailer/`. Se la copi a mano, salta `node_modules`: l'immagine si costruisce da sola.
3. **Copia il `.env`** con un canale sicuro (per esempio `scp`), poi `chmod 600 .env`. Non passa da
chat né da git: contiene la service role e la password per app.
4. **Spegni il vecchio:** `docker compose down` sul vecchio host.
5. **Avvia il nuovo:** `docker compose up -d --build` dentro `mailer/`. Devono comparire «SMTP
raggiungibile e credenziali accettate» nel log e, dopo circa 30 secondi, `healthy` in `docker ps`.
Non serve riconfigurare nulla: `APP_URL`, chiave e password restano quelle del `.env`. Se per un
po' girano entrambi i worker non ci sono mail doppie, perché `prendi_notifiche_email` usa
`FOR UPDATE SKIP LOCKED`; conviene comunque spegnere il vecchio subito, per non confondere i log.
Se il passaggio richiede tempo, le notifiche restano in coda e partono tutte insieme all'avvio del
nuovo worker.
Al primo accesso da un IP nuovo Gmail può chiedere una conferma di sicurezza sull'account. La
password per app di solito continua a funzionare; se nel log del nuovo host compare `EAUTH`,
controlla la posta dell'account mittente e conferma l'accesso.
## Diagnosi
| Sintomo | Causa probabile | Cosa fare |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Il log dice `SMTP non verificato (EAUTH)` | Password per app sbagliata, con spazi, o revocata | Controlla la lunghezza (`awk -F= '/^SMTP_PASS=/{print length($2)}' .env` deve dare 16), rigenera se serve. |
| Container `unhealthy` | Credenziali rifiutate, oppure Supabase non raggiungibile | `docker compose logs --tail 50`. |
| Righe che restano `in_coda` | Worker fermo, oppure tetto giornaliero raggiunto | `docker ps`; il tetto si libera da solo, è una finestra mobile di 24 ore. |
| Righe `saltata` | Slot senza account collegato, senza email, o email spente | Normale: nessun destinatario utile. Verifica `giocatori_squadra.email` e `auth_user_id`. |
| Righe `fallita` | Errore permanente del server di posta | Leggi `errore`: `550` = indirizzo inesistente o rifiutato. |
| La mail arriva ma in spam | Mittente nuovo, nessuna reputazione | «Non è spam» e aggiungere il mittente ai contatti; vedi i limiti noti del modulo. |
| Il pulsante «Apri CrAPP» porta a una pagina vecchia o di accesso | `APP_URL` è l'indirizzo di un singolo deploy o di un'anteprima protetta | Usa un alias stabile e ricrea il container. |
| L'interruttore «Email» non compare in Profilo | L'app pubblicata non contiene ancora il codice del modulo | Pubblica una versione che lo include. |
| Il log dice «push dei promemoria: DISATTIVATA» | Mancano `VAPID_PUBLIC_KEY` e `VAPID_PRIVATE_KEY` nel `.env` | Aggiungile (le stesse dell'app) e ricrea il container. |
| Push dei promemoria in `fallita` con errore `403` | Chiavi VAPID diverse da quelle dell'app, o payload rifiutato | Controlla le chiavi; la query sotto mostra l'errore. |
| Push dei promemoria `saltata` | Il giocatore non ha dispositivi iscritti, o il promemoria aveva oltre 3 ore | Normale se nessuno ha attivato le notifiche o il worker è stato fermo a lungo. |
## Prova in locale, senza toccare la produzione
`npx supabase start` avvia lo stack locale (la migration `m22` si applica con `npx supabase db reset`),
con Mailpit su http://127.0.0.1:54324: le mail non escono dalla macchina. Il worker si può
lanciare senza Docker con `cd mailer && set -a && . ./.env && set +a && bun mailer.mjs`.
1. **`mailer/.env` per la prova.** `SUPABASE_URL` e `SUPABASE_SERVICE_ROLE_KEY` si leggono con
`npx supabase status -o env` (`API_URL`, `SERVICE_ROLE_KEY`). Per provare **con Gmail vero** si
usano le credenziali reali e il worker gira anche senza Docker; il database resta quello locale.
Per provare **con Mailpit** bisogna far girare il worker in un container (vedi in fondo): la
porta SMTP di Mailpit non è esposta sull'host, solo la sua interfaccia web. In quel caso si
omettono `SMTP_USER` e `SMTP_PASS` e si imposta `MAIL_FROM`. `POLL_SECONDI=5` accorcia l'attesa.
2. **Un giocatore con un account collegato.** Le mail vanno solo agli slot con `auth_user_id` e con
`email`. In locale:
```bash
. <(npx supabase status -o env | grep -E '^(API_URL|SERVICE_ROLE_KEY)=')
UID_=$(curl -s -X POST "$API_URL/auth/v1/admin/users" \
-H "apikey: $SERVICE_ROLE_KEY" -H "Authorization: Bearer $SERVICE_ROLE_KEY" \
-H 'content-type: application/json' \
-d "{\"email\":\"prova-$(date +%s)@example.test\",\"password\":\"prova-12345\",\"email_confirm\":true}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
docker exec supabase_db_<project_id> psql -U postgres -c \
"set session_replication_role = replica; update giocatori_squadra set auth_user_id='$UID_' where id='<slot>';"
```
`session_replication_role = replica` serve perché il trigger di `giocatori_squadra` rifiuta la
service key. L'email dello slot deve essere quella che si vuole ricevere; è **unica** nella
tabella (`giocatori_squadra_email_unica`): se l'indirizzo compare già in un altro slot del seed,
si usa quello slot invece di impostarlo su un secondo.
3. **Una notifica di prova:**
```bash
docker exec supabase_db_<project_id> psql -U postgres -c \
"insert into notifiche_utente (giocatore_id, tipo, titolo, corpo) values ('<slot>','admin','Prova CrAPP','Messaggio di prova');"
```
Entro un ciclo di polling il log mostra `inviata …` e la mail compare in Mailpit (o nella casella).
4. **Fine prova:** `npx supabase db reset` riporta il database locale pulito.
Per provare anche l'immagine Docker, il container si collega alla rete di Supabase
(`--network supabase_network_<project_id>`) e usa `http://supabase_kong_<project_id>:8000` come
`SUPABASE_URL` e `supabase_inbucket_<project_id>` sulla porta 1025 come SMTP.
Per provare la **push dei promemoria** senza un telefono: si genera una coppia di chiavi VAPID di
prova, si avvia un piccolo server HTTP locale che finge di essere il servizio push (risponde 201 su un
percorso e 410 su un altro), si inseriscono in `push_subscriptions` due iscrizioni con chiavi ECDH
valide che puntano a quel server e si crea un promemoria in `notifiche_utente`. Il worker lo cifra, lo
spedisce con `Authorization: vapid …`, `Content-Encoding: aes128gcm`, `Urgency: high` e `TTL`, segna
la riga `inviata` ed elimina l'iscrizione che ha risposto 410. Il 29/09/2026 la prova è stata fatta
così.
L'app in sviluppo (`npm run dev`) richiede un Node più recente del 18: su un host con Node 18 Vite
non parte, e con lui i test di integrazione che avviano il server. Si può usare bun al posto di Node:
`bun --bun x vite dev --port 8080` (con `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` e
`SUPABASE_SERVICE_ROLE_KEY` dello stack locale) e poi `BASE_URL=http://localhost:8080` davanti ai
test. I test del database e del worker non ne hanno bisogno.
## Sicurezza
- Il `.env` contiene la service role e la password per app: permessi `600`, mai in git, mai in chat.
- La service role non passa da Vercel: sta solo sull'host del worker. Anche la chiave VAPID
**privata** sta sull'host, oltre che su Vercel: stessa attenzione, mai in git né in chat.
- Il log delle push riporta solo id notifica e numeri: mai gli endpoint, che identificano il
dispositivo del giocatore.
- Se la password per app o la service role sono state esposte, si revocano/ruotano subito (Google e
dashboard Supabase) e si aggiorna il `.env` con `--force-recreate`.
+8
View File
@@ -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. |
@@ -1,5 +1,5 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
+24
View File
@@ -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.
+24
View File
@@ -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.
+26
View File
@@ -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.
+22
View File
@@ -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.
+23
View File
@@ -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).
+22
View File
@@ -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).
+23
View File
@@ -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.
+22
View File
@@ -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.
+24
View File
@@ -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.
+23
View File
@@ -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.
+24
View File
@@ -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).
+22
View File
@@ -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.
+22
View File
@@ -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.
+16
View File
@@ -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.
+30
View File
@@ -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.
+45
View File
@@ -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.
+37
View File
@@ -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.
+25
View File
@@ -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`.
+33
View File
@@ -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.
+31
View File
@@ -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.
+40
View File
@@ -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).
+32
View File
@@ -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.
+59
View File
@@ -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.
+60
View File
@@ -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.
+54
View File
@@ -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.
+44
View File
@@ -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).
+52
View File
@@ -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.
+50
View File
@@ -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.
+77
View File
@@ -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).
+71
View File
@@ -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.
+90
View File
@@ -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.
+40
View File
@@ -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()`.
+35
View File
@@ -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.
+51
View File
@@ -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.
+58
View File
@@ -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.
+76
View File
@@ -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.
+55
View File
@@ -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.
+68
View File
@@ -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.
+40
View File
@@ -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.
+94
View File
@@ -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.
+39
View File
@@ -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.
+36
View File
@@ -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).
+35
View File
@@ -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.
+38
View File
@@ -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.
+34
View File
@@ -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.
+33
View File
@@ -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.
+106
View File
@@ -0,0 +1,106 @@
# 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-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 |
+211
View File
@@ -0,0 +1,211 @@
# Modulo — Ruolo Allenatore
**Stato:** implementato
**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`,
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
`test/integration/permessi-route.test.ts`
## Obiettivo
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
riceve voti e non ha statistiche, badge o stagione.
## Registrazione
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
3. Entra in Home.
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
## Profilo
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
| Tab | Giocatore | Allenatore |
| ------------ | --------- | ---------------------------------------------- |
| Stagione | sì | **no** |
| Badge | sì | **no** |
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
### Dati modificabili dall'allenatore
| Campo | Dove sta oggi | Nota |
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
| Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico,
tesseramento CSI.
### Completamento profilo
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
quando quei campi sono compilati.
## Home
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
vedono solo gli admin e il giocatore interessato (DD-035).
## Squadra
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
statistiche, e non compare nella tab Stats né nelle classifiche.
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
non compaiono.
| Tab | Giocatore | Allenatore |
| --------- | --------- | ---------- |
| Rosa | sì | sì |
| Stats | sì | sì |
| Obiettivi | sì | **no** |
| Badge | sì | **no** |
## Badge e cacche nascosti
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
Presenze, media voto, MVP e palloni restano visibili.
## Gestione eventi
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. 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
risposte presenze altrui, gestione dei ruoli.
## Notifiche
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 (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. |
| 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
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
funziona come per i giocatori, con i soli messaggi dello staff.
## Esclusione dalla rosa
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
includono.
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
giocatore).
## Permessi (riepilogo)
| Azione | Giocatore | Allenatore | Admin |
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
## Implementazione
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
lascia usare un valore di enum nella transazione che lo crea.
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore.
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
## Scelte confermate
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
messaggi dello staff.
- Sollecita le presenze: sì. Promemoria palloni: no.
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
posto del ruolo.
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
gli autenticati.
+177 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Badge
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
@@ -26,16 +26,45 @@ badge assegnati per voto dai compagni.
## Implementazione
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
(modificabile), con **auto-voto escluso sia in UI sia in logica** (`VotoSocial.tsx`). A
(modificabile), con **auto-voto escluso in interfaccia** (`VotoSocial.tsx`) e rifiutato dal
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
@@ -45,6 +74,61 @@ badge assegnati per voto dai compagni.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 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.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `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)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
`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`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| ------------ | -------------------------- | ------------------------------------------------ |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
@@ -54,6 +138,59 @@ badge assegnati per voto dai compagni.
---
## Copertura test
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 | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`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 | ✅ | ✅ (`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`) |
---
## Problemi noti da sistemare
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
@@ -63,11 +200,39 @@ badge assegnati per voto dai compagni.
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- RLS permissiva su `badge_social_voti` (stesso schema di `mvp_voti`): nessun controllo
server-side che `votante_id` coincida con l'utente autenticato.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
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`
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
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
---
## Evoluzioni possibili
@@ -75,3 +240,7 @@ badge assegnati per voto dai compagni.
- 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,
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`.
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
+80
View File
@@ -0,0 +1,80 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
---
## Obiettivo
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
## Due schermate, due pubblici
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`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
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../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;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
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
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
## Lettura lato server
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
---
## Limiti noti
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[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 [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`
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
+286 -37
View File
@@ -1,7 +1,8 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
@@ -21,26 +22,134 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
API key, ma **non offrono alcuna garanzia di stabilità**.
| Endpoint | Formato | Uso |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `team-roster.php`, `team-results.php`.
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| Cosa | Valore |
| ------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` | `767` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| Cosa | Valore |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../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
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
@@ -49,29 +158,84 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓
/classifica → src/routes/classifica.tsx
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili, gare divise
per stagione; stagione anche nel sottotitolo dell'header)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
↓
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
↓
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
@@ -83,18 +247,103 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
a mano in `csi-core.ts`.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
pagina campionato molto meno. Il fix, quando succede, è isolato a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.
+8 -1
View File
@@ -27,6 +27,12 @@ volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
altro codice.
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
profilo che mostri il numero di infortuni come statistica di superficie.
@@ -38,7 +44,8 @@ profilo che mostri il numero di infortuni come statistica di superficie.
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
invece di "assente": nessuna validazione o promemoria lo garantisce.
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato.
+30 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Votazione MVP
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
---
@@ -17,16 +17,29 @@ calcolato a runtime.
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, sovrascrivibile.
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
restano nel database ma non vengono più letti da nessuna schermata).
---
## Implementazione
- Il pannello compare in `partita.$id.tsx` solo se esiste un risultato (Scout Live salvato)
per la partita, altrimenti mostra "la partita non è ancora stata disputata".
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
quando aprirà. Nessun legame con il risultato caricato.
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
@@ -35,18 +48,23 @@ partita, sovrascrivibile.
## Limiti noti
- **Nessun controllo che impedisca di votare se stessi** — a differenza dei
[Badge social](badge.md), che escludono esplicitamente l'auto-voto. È una lacuna, non un
limite di design dichiarato altrove.
- Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
- RLS permissiva: `votante_id`/`votato_id` sono testo libero inviato dal client (l'id
giocatore proviene da `localStorage`, non da un claim di sessione verificato server-side);
nessun trigger lega il voto all'utente autenticato.
- In caso di parità, nessun MVP viene assegnato per quella partita.
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
partita.
---
## Evoluzioni possibili
- Impedire l'auto-voto come già avviene nei Badge social.
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
+243
View File
@@ -0,0 +1,243 @@
# Modulo — Notifiche email
**Stato:** implementato e in produzione dal 29/09/2026 (migration `m22`, worker in `mailer/`)
**File principali:** `supabase/migrations/20260929120000_m22_notifiche_email.sql`,
`mailer/mailer.mjs`, `mailer/mailer-core.ts`, `mailer/healthcheck.mjs`, `mailer/Dockerfile`,
`mailer/docker-compose.yml`, `src/lib/preferenze-utente.ts`, `src/routes/profilo.tsx`
(interruttore «Email»)
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](../decisions/DD-036.md).
## Obiettivo
Far arrivare le notifiche di CrAPP anche per email, all'indirizzo Gmail con cui il giocatore si è
registrato, **a costo zero**. È un terzo canale accanto alla push e al centro notifiche in-app
([notifiche.md](notifiche.md)): non li sostituisce e non introduce eventi nuovi. Manda per email
**ogni notifica che finisce già in `notifiche_utente`**, di qualunque tipo e per qualunque
destinatario.
## Architettura
```
App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ notifiche_email_coda (Supabase)
▲
polling in uscita │ RPC con service role
│
Container Docker sull'host di casa (mailer)
│ SMTP 587 (STARTTLS)
▼
smtp.gmail.com ──▶ casella Gmail del giocatore
```
- **Vercel non ospita Docker**, quindi il worker gira su un host proprio e sta **solo in uscita**:
interroga Supabase e parla con Gmail. Nessuna porta aperta sul router, nessun IP pubblico,
nessun tunnel.
- **L'app non invia mai email direttamente.** Continua a scrivere `notifiche_utente` come prima:
le route e i cron di M17 non sono stati toccati. Se l'host è spento le mail restano in coda e
partono alla ripartenza: consegna in ritardo, mai persa.
- **L'accodamento sta nel database** (un trigger), quindi vale per ogni sorgente, cron `pg_cron`
compresi.
- Il worker gira con **bun** (immagine `oven/bun:1-alpine`, come il resto del progetto) e ha una
sola dipendenza, `nodemailer`. Parla con Supabase via `fetch` sulle funzioni RPC di PostgREST,
senza `@supabase/supabase-js`. Ha un proprio `package.json` e `bun.lock`, separati da quelli
dell'app, che non guadagna nessuna dipendenza.
- La chiave con cui il worker si autentica può essere sia una chiave JWT sia una chiave nel formato
nuovo `sb_secret_…`: viene inviata in `apikey` e in `Authorization: Bearer` e Supabase accetta
entrambe (verificato sul progetto di produzione).
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **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.
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
arrivano anche per email (DD-038).
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
`notifiche_utente`, da decidere a parte.
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
## Mittente
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
una **password per app**. Come crearla e i problemi noti stanno in
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
le mail finiscono in spam o vengono rifiutate.
## Dati
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
funzionale.
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
anche la mail non ancora partita).
| Colonna | Note |
| -------------------- | ----------------------------------------------------------------------- |
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
legge e la scrive solo la service role, cioè il worker.
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
modificare solo in parte (DD-016).
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
| Funzione | Cosa fa |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
## Flusso
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
dei cron non toccano quella colonna e non accodano nulla.
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
o con le email spente.
4. Ogni invio registra l'esito con `esito_notifica_email`.
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
ferma anche le mail già in coda.
## Affidabilità
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
| Tipo | Esempi | Cosa succede |
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
Altre situazioni:
| Situazione | Comportamento |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
messaggi che l'account dedicato potesse mandare per altre vie.
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
giornaliero è normale e resta sano.
## Contenuto delle mail
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
## Interfaccia
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
Nessuna schermata nuova.
## Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
Vercel.
- La password per app si può revocare da Google senza toccare l'account.
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
per la push (nessuna preferenza per tipo).
## Test
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
«acceso» senza riga.
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
## Limiti noti
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
una.
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
che `notifiche_utente` ha una riga per `giocatore_id`.
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
`inviata` non si toccano.
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
spariscono quando la notifica viene eliminata.
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
## Evoluzioni possibili
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
restano in spam o si supera il tetto di Gmail.
- Riepilogo settimanale unico al posto di una mail per notifica.
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
+276 -13
View File
@@ -1,9 +1,11 @@
# Modulo — Notifiche
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
---
@@ -12,10 +14,156 @@
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
presenze, 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.
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
---
## Catalogo delle notifiche
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, **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~~ — **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 |
Le notifiche generate dal server (righe 1-6) 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 e 5; 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.` |
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. 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.
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
### Comportamenti da conoscere
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
allenamenti e partite.
- **Un evento creato a poche ore dall'inizio riceve subito i promemoria che gli spettano:** se
mancano meno di 24 ore parte il promemoria «24 ore» al primo giro del job orario, e se ne
mancano meno di 3 anche quello «3 ore», a distanza di pochi minuti.
- **Ogni promemoria si genera una sola volta per giocatore, evento e tipo, anche se il giocatore
lo elimina** (M23, DD-037): il job registra ciò che ha generato in `promemoria_eventi_generati`,
una tabella indipendente da `notifiche_utente`. Eliminare la notifica dal centro notifiche non
la fa tornare e non accoda una nuova mail. Prima di M23 tornava: la riga eliminata veniva
rigenerata al giro successivo del job, con una nuova mail a ogni giro fino all'inizio
dell'evento.
- **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 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.**
- **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.
- **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 (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` |
| 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
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
codice; l'unico pezzo puro, `inRosa`, è coperto.
---
## Dati
@@ -55,14 +203,15 @@ copre in particolare le sessioni lunghe della webapp (vedi il
- **`push-config`** — espone la sola chiave pubblica VAPID.
- **`push-subscribe`** — registra o rimuove l'iscrizione di un dispositivo.
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a **tutti** i
dispositivi iscritti l'avviso di apertura del sondaggio pre-partita (vedi
[Scout Live](scout-live.md)).
- **`apri-sondaggio`** — premuto da un admin dalla pagina partita: manda a tutti i dispositivi
iscritti (allenatori esclusi) l'avviso di apertura del sondaggio pre-partita e lo scrive anche in
`notifiche_utente` per i giocatori attivi, così compare nel centro notifiche e parte per email
(DD-038; vedi [Scout Live](scout-live.md)).
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
@@ -81,10 +230,27 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
| Route | Controllo | Chi la chiama |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
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.
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
l'ha ancora attivata.
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
---
@@ -96,8 +262,105 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto.
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
---
## Centro notifiche in-app (M17)
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
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 (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)`
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
da `avvisiPalloniEvento()`, in parallelo alla push.
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
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
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
separato.
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Push dei promemoria (M24)
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).
1. Quando il job crea un promemoria, un trigger lo accoda in `notifiche_push_coda` (solo i tipi
`evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano già la push
dall'app, e una seconda sarebbe un doppione).
2. Il worker, a ogni giro, chiama `prendi_push_promemoria()`, che restituisce testo e dispositivi
iscritti del giocatore, e manda la push a **tutti** i suoi dispositivi con lo stesso modulo
dell'app (`src/lib/webpush.server.ts`), quindi con le stesse regole di cifratura, urgenza e TTL.
3. Un solo esito per notifica: `inviata` se almeno un dispositivo accetta; `riprova` con backoff
1 · 5 · 30 · 120 minuti sugli errori temporanei (429, 5xx, rete), poi `fallita`; `fallita`
subito su un rifiuto permanente (400, 401, 403, 413: tipicamente chiavi VAPID sbagliate);
`saltata` se le iscrizioni erano tutte scadute o il giocatore non ne ha.
4. Le iscrizioni che rispondono 404 o 410 vengono eliminate da `push_subscriptions`, come fanno le
route.
5. Una push in coda da oltre 3 ore dalla creazione del promemoria si scarta (`saltata`): a evento
ormai iniziato sarebbe solo rumore. Vale se il worker resta fermo a lungo.
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
un interruttore «Email» per account in Profilo → Opzioni: [notifiche-email.md](notifiche-email.md).
Non cambia nulla di quanto descritto sopra: push, notifiche smart e centro notifiche restano
indipendenti e le sorgenti scrivono `notifiche_utente` come prima. Il certificato in
scadenza resta una card in Home e non genera mail (DD-035).
## Limiti noti
- Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo
+142 -29
View File
@@ -1,6 +1,7 @@
# Modulo — Obiettivi di squadra
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
**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()`)
---
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
## Dati
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
serie).
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
ordina mettendo i completati in coda e gli altri per progresso decrescente.
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
cancellazione: risolto a livello database con un trigger (migration
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
corrente dinamico (vedi sotto).
---
## Obiettivi definiti
| Obiettivo | Calcolo | Target | Fonte |
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
| 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` |
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`).
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 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.
---
## Limiti noti
## Obiettivi mensili — mese dinamico
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
squadra risponde alle convocazioni, non solo la presenza.
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
sono "congelati" su un mese già passato.
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
ogni stagione.
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
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.
`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 è
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
---
## Evoluzioni possibili
## Continuità di squadra — il target 12 è il minimo per un 6vs6
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
---
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
proprio target, nemmeno con più vittorie di quante ne servano.
### Il limite: il parsing del JSON può rompersi in silenzio
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
comunque `200` con `partite: []`.
### Come è mitigato oggi
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
anche loro).
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
### Come fixarlo, se succede
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
restano verdi contro un formato che non esiste più.
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
[Collegamento CSI](collegamento-csi.md).
---
## Copertura test
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) |
- **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.
- **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
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
arrotondata e conteggio.
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
verifica cap e target su dati reali.
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
+25 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Pagelle
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
---
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
`squadra.tsx`.
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
---
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
voto in UI.
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
`evento_permette_voto()`, `pagelle_voti`).
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
database, non solo in UI (stessa RLS di sopra).
---
## Limiti noti
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
- Nessun controllo a livello database che il votante sia realmente un convocato della
partita: solo filtro applicativo.
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
coincide con quel voto.
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
considerarla — profilo e squadra no.
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
---
## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+40 -14
View File
@@ -1,6 +1,6 @@
# Modulo — Palloni
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
`src/routes/api/public/promemoria-palloni.ts`
@@ -10,8 +10,9 @@
## 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.
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.
---
@@ -25,16 +26,28 @@ compaiono.
## Implementazione
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
- `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,
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
incaricato in `TurnoPalloni` (scelta della squadra).
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
confermate** (partite/eventi) — non è uno storico in tabella dedicata.
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
@@ -42,6 +55,13 @@ compaiono.
## 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
@@ -56,13 +76,19 @@ 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.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
dedicato in `palloni-core.test.ts`.
---
+41 -15
View File
@@ -1,8 +1,8 @@
# Modulo — Presenze
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/routes/api/public/sollecita-presenze.ts`
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
---
@@ -24,7 +24,11 @@ attuale.
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
risposto".
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
(`RosaPresenze`, prop `tipo`).
---
@@ -40,25 +44,46 @@ risposte_presenze (Supabase)
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
↓ legge tutta la tabella)
RosaPresenze → src/components/crapp/RosaPresenze.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
partita.$id.tsx / evento.$id.tsx
EventoCard → src/components/crapp/EventoCard.tsx
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
--- statistiche ---
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
(percentuale ultimi 30gg, da cache già in memoria)
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
--- sollecito (solo admin) ---
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
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
├─ destinatariSollecito() → src/lib/presenze.ts
│ (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`.
@@ -69,25 +94,26 @@ 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.
- 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
(DD-023). Verificato da `test/integration/permessi.test.ts`.
---
## Limiti noti
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
- Il controllo "solo il giocatore risponde per sé" è solo lato UI: le policy RLS di
`risposte_presenze` permettono a qualunque utente autenticato di scrivere qualunque riga
(`USING(true) WITH CHECK(true)`).
- La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
finestra di chiusura, né in UI né in RLS.
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
una singola squadra, da rivedere se il volume cresce molto.
- La route `/api/public/sollecita-presenze` non verifica lato server che il chiamante sia
admin: la protezione è solo nell'interfaccia (bottone visibile solo se `useIsAdmin()`).
---
## Evoluzioni possibili
- Restringere anche lato RLS/route chi può scrivere una risposta o chiamare il sollecito.
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
+179 -17
View File
@@ -1,5 +1,11 @@
# Modulo — Profilo Giocatore
**Stato:** implementato
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`
---
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
@@ -8,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
## Utenti
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
la specifica sta in [allenatore.md](allenatore.md).
### Giocatore
Può:
@@ -29,12 +38,13 @@ Può:
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
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
@@ -54,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
## Home
Il giocatore visualizza un widget dedicato.
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
### Completa il tuo profilo
@@ -63,13 +73,147 @@ 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.
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
sulla tab Stagione.
### Avviso certificati
**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
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 | **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
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
nuovo, come il widget «Completa il tuo profilo».
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
solo proprio a un giocatore.
#### Quando compare
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
staff.
| 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** (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'`).
#### Testi
| Avviso | Titolo (staff / personale) | Riga |
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
scadono molti insieme.
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
nero, poi quello giallo.
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
#### Aspetto e posizione
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
di confondersi con lo sfondo.
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
ciò che scade oggi, poi ciò che manca.
#### Rinnovo
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
data, chiunque l'abbia scritta.
#### Caricamento
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
la Home non deve rompersi per un dato accessorio.
#### Implementazione
| Pezzo | Ruolo |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia 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.
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili.
- Test in `test/unit/profili-core.test.ts`: 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.
## Profilo
Il profilo viene suddiviso in sette aree.
@@ -85,7 +229,11 @@ Il profilo viene suddiviso in sette aree.
**Dati personali** — modificabili dal giocatore.
- Data di nascita
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
anche quel valore pubblico.
- Luogo di nascita
- Indirizzo di residenza
- Telefono
@@ -118,13 +266,16 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file.
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
@@ -159,14 +310,24 @@ Contiene.
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Opzioni).
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
c'è almeno un giocatore disattivato) e Notifiche.
Per ogni giocatore vengono mostrati.
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Foto tessera
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
@@ -174,7 +335,6 @@ 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
@@ -215,10 +375,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%.
@@ -230,6 +389,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
Non carica file al posto di altri.
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
## Versione 1
- Profilo giocatore
@@ -237,13 +399,13 @@ Non carica file al posto di altri.
- Gestione dati personali
- Documento di identità
- Certificato medico
- Foto tessera
- Dashboard amministratore
- Esportazione CSV CSI
## Versioni future
- Storico certificati medici
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
- Gestione documenti aggiuntivi
- Consensi privacy
- Firma digitale
+1 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
**Stato:** implementato (fix M7 per la persistenza condivisa)
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
+36 -11
View File
@@ -4,7 +4,9 @@
**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:** `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`
---
@@ -58,6 +60,18 @@ UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
per le conferme.
Le due colonne si confondono facilmente, e sbagliarle non rompe niente di visibile: la serie
comincia solo a raccontare il falso. Per questo il confine è verificato in
`test/integration/scritture.test.ts` («la risposta di presenza si aggiorna senza far ripartire
il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto_il` e controlla
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 |
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
@@ -102,8 +116,11 @@ ordine:
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
quell'evento e la sua serie non si spezza.
2. **Scarta il futuro** (`e.data <= oggi`). Gli eventi di oggi contano già: se serve un
confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data).
2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
Il confronto è **stretto**: l'evento di oggi non conta ancora, perché nessuno ha potuto
presentarsi e conterebbe come assenza, azzerando la serie di tutta la squadra la mattina
della partita. Entra in gioco dal giorno dopo. Il parametro `oggi` è iniettabile — di
default `dataOggi()` — e i test lo fissano a una data per non dipendere dall'orologio.
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
@@ -121,9 +138,10 @@ Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili so
Conseguenze da conoscere prima di cambiare qualcosa:
- **`infortunato` azzera la serie**, esattamente come `assente`. Coerente con il conteggio
presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per
infortunio.
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
@@ -265,9 +283,10 @@ il costo diventerebbe per-render e andrebbe stabilizzata a monte.
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
- **Cambiare la regola di presenza** (per esempio non azzerare su `infortunato`) → il
predicato dentro `serieConsecutiva()`. Valutare se allineare anche
`contaPresenzeGiocatore()`, che oggi usa lo stesso criterio.
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
assenza ai fini statistici.
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
@@ -298,8 +317,14 @@ nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non determini
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle
prime ore della giornata italiana un evento di oggi può risultare "non ancora passato".
**`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
`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".
---
+98
View File
@@ -0,0 +1,98 @@
# Modulo — Squadra
**Stato:** implementato
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
---
## Obiettivo
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](../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.
---
## Due letture diverse, per non pagare due volte lo stesso costo
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita
viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`,
DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il
giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null`
finché non è stata inserita da nessuno.
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
(`obiettivi-squadra.md`).
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
## Allenatori in Squadra
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../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](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
([DD-018](../decisions/DD-018.md)).
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
dell'admin.
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
all'amministratore.
## Classifica interna di Squadra
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive").
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
---
## Limiti noti
1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
quella reale.
+28
View File
@@ -0,0 +1,28 @@
# Copia in .env (permessi 600) e compila. Il .env non va mai committato.
# Progetto Supabase e chiave service role (salta la RLS: trattala come una password)
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=
# Account Gmail dedicato con verifica in due passaggi e una password per app
SMTP_USER=crapp.notifiche@gmail.com
SMTP_PASS=
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:tuo@indirizzo
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
APP_URL=https://<dominio-dell-app>
# Facoltative (valori di default)
# MAIL_FROM_NAME=CrAPP
# MAIL_FROM= # default: SMTP_USER
# POLL_SECONDI=30
# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail
# MAIL_LOTTO=10
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false # true solo per la porta 465
+11
View File
@@ -0,0 +1,11 @@
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
FROM oven/bun:1-alpine
WORKDIR /app/mailer
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
RUN bun install --frozen-lockfile --production
COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
USER bun
CMD ["bun", "mailer.mjs"]
+11
View File
@@ -0,0 +1,11 @@
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
# nel contesto né il .env né il resto del progetto.
*
!mailer/package.json
!mailer/bun.lock
!mailer/bunfig.toml
!mailer/mailer.mjs
!mailer/mailer-core.ts
!mailer/healthcheck.mjs
!src/lib/webpush.server.ts
+15
View File
@@ -0,0 +1,15 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "crapp-mailer",
"dependencies": {
"nodemailer": "^10.0.11",
},
},
},
"packages": {
"nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="],
}
}
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff. Show more