46 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 a69bcb3f0d Porta la versione a 1.4.2: link Maps, testo «Apri su Maps»
Il pulsante delle schede di partita, allenamento ed evento dice «Apri su Maps».
CHANGELOG con la sezione 1.4.2 (m34 applicata in produzione il 26/10/2026),
PROJECT_STATE e package.json/package-lock alla 1.4.2.

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:12:07 +01:00
davide 2f583ca97f Merge develop: versione 1.4.1 2026-10-19 08:31:53 +02:00
davideandClaude Sonnet 5.5 7996dd050e CHANGELOG: la correzione della card evento è parte della 1.4.1
Sposta la voce da «Non rilasciato» alla sezione 1.4.1.

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:12:02 +02:00
davideandClaude Sonnet 5.5 3bf07049e9 Porta la versione a 1.4.0: «Presenza modificata» + copertura test e docs
Sezione 1.4.0 del CHANGELOG, versione in package.json e package-lock.json,
PROJECT_STATE aggiornato. Il test di presenza-modificata copre ora i confini
delle 6 ore, ogni stato, allenatore inattivo, admin senza slot, doppio ruolo e
ora dell'evento non valida. Documentazione delle notifiche allineata (riga 9,
copertura test, email, DATABASE). Nessuna nuova migration: la m32 e la m33 sono
applicate in produzione (09/10/2026).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:24:11 +02:00
davideandClaude Sonnet 5.5 986098d933 DD-050: «Presenza modificata» anche agli allenatori (m33)
Fine della fase di prova: la funzione avvisa admin e allenatori attivi. Test
aggiornato (l'allenatore ora è tra i destinatari), DD-050, notifiche, allenatore,
DATABASE, CHANGELOG, ROADMAP e PROJECT_STATE allineati. m33 non ancora applicata
in produzione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 11:06:46 +02:00
davideandClaude Sonnet 5.5 274fed93c5 Implementa DD-050: avviso «Presenza modificata», in prova solo per gli admin
Migration m32: tipo presenza_modificata, funzione e trigger su risposte_presenze
che avvisano gli admin attivi se una risposta cambia a 0-6 ore da una partita o
un allenamento (push, email e centro notifiche). Gli allenatori non la ricevono
finché dura la prova. Applicata in produzione il 09/10/2026.
Test di integrazione e unitario; DD-050, notifiche, DATABASE, CHANGELOG,
ROADMAP e PROJECT_STATE aggiornati (slot g18 ora giocatore).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 10:54:47 +02:00
davideandClaude Sonnet 5.5 f0e6a3a6cc Documenta DD-050: avviso ad admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
Solo documentazione: nessun codice né migration. Decisione in valutazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:47:12 +02:00
davideandClaude Sonnet 5.5 daa0dd9c60 Porta la versione a 1.3.1: «Prossimi compleanni» nel Calendario
La sezione in fondo al Calendario mostra solo i compleanni del mese in corso non
ancora passati. Sezione 1.3.1 del CHANGELOG, versione in package.json e
package-lock.json, PROJECT_STATE e modulo calendario aggiornati. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:29:08 +02:00
davideandClaude Sonnet 5.5 bfb768fb5a Rimuove la serie «Conferme 24h» e i badge collegati
Toglie la serie dal profilo, dalla Home e dalle notifiche di traguardo, insieme
ai badge «Risposta lampo» e «Mai un forfait» e al calcolo di serieConferme.
La colonna risposto_il e il suo trigger restano nel database, inutilizzati.
Test e documentazione allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:21:14 +02:00
davideandClaude Sonnet 5.5 8d0a8c7971 Merge develop in main: versione 1.3.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:54:28 +02:00
davideandClaude Sonnet 5.5 29acb326a7 Porta la versione a 1.3.0: changelog e stato del progetto
Sezione 1.3.0 del CHANGELOG con tutto quanto dopo v1.2.3 (notifica di compleanno,
obiettivi cliccabili, presenze sui convocati, avvisi in Home, caratteri più
grandi, tessera CSI tolta dalla dashboard). Versione in package.json e
package-lock.json, PROJECT_STATE aggiornato. Migration da applicare: m31.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:54:28 +02:00
davideandClaude Sonnet 5.5 77e5daaa7a Caratteri più grandi in tutta l'app (DD-049); etichetta «Cacche» nella rosa
Ogni testo sotto i 16 px sale di un passo in tutte le pagine (12→13, 13→14,
14→15, 15→16); restano invariate le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge da 10-11 px. Nella scheda giocatore di Squadra
l'etichetta «Cacche/partita» diventa «Cacche» (il valore resta la media).
Documentazione: DD-049, CHANGELOG, ARCHITECTURE, PROJECT_STATE, moduli squadra
e allenatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:50:28 +02:00
davideandClaude Sonnet 5.5 519f9aaff4 Avvisi in Home: componente condiviso, più leggibili; turno palloni ridisegnato
Certificati e turno palloni usano lo stesso componente Avviso: icona propria per
ogni caso (il colore non è l'unico segnale), testo più grande, elenchi di persone
in righe con dettaglio a destra e conteggio, freccia sulle card cliccabili,
comparsa graduale. Il turno palloni ha la pillola «Oggi»/«In arrivo» e i
messaggi in riquadri. Solo aspetto: testi e regole di comparsa invariati.
Test del componente, CHANGELOG, ARCHITECTURE e test/README aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:44:58 +02:00
davideandClaude Sonnet 5.5 e7ae2c1884 Toglie numero e data tessera dalla dashboard (DD-048); docs allineate
Spariscono dalla scheda del giocatore in /admin il blocco «Tesseramento CSI» e il
salvataggio, il badge «Tesserato/Da tesserare» e il riquadro «Tesserati» della
tab Squadra. L'export CSV non cambia. Le colonne numero_tessera e data_tessera
restano a database, non più usate; nessuna migration.

Aggiornati PROJECT_STATE, CHANGELOG, ARCHITECTURE, DATABASE, ROADMAP, i moduli
profilo-giocatore, squadra e obiettivi-squadra, test/README e l'indice delle
decisioni.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:39:11 +02:00
davideandClaude Sonnet 5.5 c2c3b249f2 Obiettivi cliccabili con card di dettaglio; presenze del mese sui convocati
Ogni obiettivo di squadra apre un drawer che spiega come si calcola, cosa conta,
cosa è una presenza persa, cosa resta fuori, periodo, fonte dei dati ed esempio.
«90% di presenze del mese» usa ora i convocati dell'evento come posti (tutta la
rosa se non ce ne sono), come le presenze collettive: chi non è convocato non
abbassa più la percentuale. Test unit, card e integrazione; docs aggiornate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:58 +02:00
davideandClaude Sonnet 5.5 6b8cc33512 Obiettivo Presenze collettive: percentuale al 90% su allenamenti e partite definiti
Sostituisce «250 presenze complessive». Il valore è la percentuale di presenze
(presente o ritardo) su tutti gli allenamenti e le partite in calendario, passati
e futuri, con i convocati come posti (tutta la rosa se non ce ne sono). Stagione
intera, nessun reset mensile.

Test unitari ampliati (arrotondamento, convocati, ora legale, cambio mese) e test
di integrazione di o6 riallineato alla regola dell'ora dell'evento. Documentazione
e CHANGELOG aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:06:51 +02:00
davideandClaude Sonnet 5.5 ff02b7439c Obiettivo evento di squadra al mese: conta quando arriva l'ora dell'evento
Prima bastava definire l'evento nel mese; ora data e ora (Europe/Rome)
devono essere già passate. Senza ora vale 00:00. Test e documentazione
aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:38:19 +02:00
davideandClaude Sonnet 5.5 985fc2fcc1 Notifica di compleanno: auguri al festeggiato, avviso agli altri (DD-047)
Ogni giorno, dalle 8:00 (Europe/Rome), un job pg_cron orario manda a chi compie
gli anni «Buon compleanno, <Nome>! 🎂» e a tutti gli altri membri attivi,
allenatori compresi, «Oggi è il compleanno di <Nome>» (con più compleanni lo
stesso giorno un solo messaggio a testa, nomi in ordine alfabetico). Solo
titolo, senza corpo né età. Canali: push, centro notifiche ed email.

- migration m31: tipi `compleanno` e `compleanno_auguri`, registro
  `compleanni_notificati` (una sola volta per destinatario e giorno, anche se la
  notifica viene eliminata), funzione `genera_avvisi_compleanno()` e job
  `avvisi-compleanno`, trigger push esteso ai nuovi tipi. Non ancora applicata
  in produzione.
- 29 febbraio non gestito: chi è nato quel giorno è avvisato solo negli anni
  bisestili (limite noto, documentato).
- test: integrazione `notifiche-compleanno` (ora e ora legale, destinatari,
  più compleanni, registro, coda push/email, 29 febbraio, job pianificato);
  unit sui nuovi tipi e sul corpo vuoto del service worker.
- documentazione: DD-047, catalogo e testi delle notifiche, DATABASE, CHANGELOG,
  ROADMAP, PROJECT_STATE.

Chiude la issue #11.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:18:22 +02:00
davideandClaude Sonnet 5.5 7347fdba6e Avviso certificati mancanti (DD-046), dipendenze aggiornate, 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.

Aggiorna @tanstack/react-start alla 1.168.60 (con router, plugin e query) e
chiude le vulnerabilità di npm audit: Vercel bloccava il deploy con la
1.168.34. Il tipo di error in __root.tsx diventa unknown, come nel nuovo tipo
di TanStack.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:29:47 +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
193 changed files with 13549 additions and 5785 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
+3 -1
View File
@@ -48,4 +48,6 @@ backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
!backups/README.md
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
ops/
+15 -5
View File
@@ -11,17 +11,22 @@ usabile dallo smartphone anche da chi non è pratico.
## Prima di modificare il codice
1. Leggi l'indice [docs/README.md](docs/README.md) e segui l'ordine di lettura che indica; poi
il documento del modulo interessato in [docs/modules/](docs/modules/).
il documento del modulo interessato in [docs/modules/](docs/modules/). Le decisioni
([docs/decisions/](docs/decisions/README.md)) si leggono una per una, solo quelle che il modulo
richiama o che l'indice indica per il tema: non servono tutte. Per i termini del dominio c'è il
[glossario](docs/GLOSSARIO.md); le «Trappole note» in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) vanno lette prima di toccare codice o schema.
2. Verifica lo stato attuale del repository: commit recenti, modifiche non committate, lavoro
introdotto da altri collaboratori o da altri assistenti.
3. Non presumere che il progetto sia come l'hai lasciato nell'ultima sessione: la fonte di
verità è il repository, non la cronologia della conversazione.
Non implementare funzionalità non documentate: prima si documenta
([DD-002](docs/DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)), poi si scrive il codice.
([DD-002](docs/decisions/DD-002.md)), poi si scrive il codice.
## Comandi
Variabili d'ambiente, ambienti e rilascio sono in [docs/OPERATIONS.md](docs/OPERATIONS.md).
Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
`minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
@@ -82,9 +87,14 @@ Non committare, non fare push e non aprire PR senza che l'utente lo abbia chiest
Ogni modifica significativa deve lasciare una traccia leggibile senza la cronologia delle
conversazioni: commit con messaggio descrittivo, più il documento giusto tra
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
[PROJECT_STATE.md](PROJECT_STATE.md) (a che punto siamo ora, in una pagina: non il registro delle
migration né l'elenco delle funzioni),
[docs/decisions/](docs/decisions/README.md) (decisioni architetturali, un file `DD-XXX.md` ciascuna),
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema),
[docs/OPERATIONS.md](docs/OPERATIONS.md) (se cambiano variabili d'ambiente o procedure di rilascio).
Una decisione nuova è un file `docs/decisions/DD-XXX.md` copiato da `docs/_template-dd.md`, più la
riga nell'indice. Per aggiungere una funzione o un modulo segui la checklist in
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
Quali contenuti vanno in quale file, e le convenzioni di scrittura, stanno nelle regole di
manutenzione di [docs/README.md](docs/README.md): ogni informazione ha una sola casa, non
+35 -181
View File
@@ -1,191 +1,45 @@
# Project State
Ultimo aggiornamento: 27/09/2026
Ultimo aggiornamento: 06/10/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
[docs/OPERATIONS.md](docs/OPERATIONS.md). Non ripetere qui quello che hanno già loro.
## Stato generale
Fase corrente:
- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018).
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0
con il ruolo allenatore, è pronta ma non ancora pubblicata (vedi `docs/CHANGELOG.md`).
Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane
da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
## 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`
- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte
applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026)
- Sviluppo locale verificato con il nuovo Supabase
---
## Backend
- Backend operativo: Supabase proprietario (`kfkcldwncxqaixetsjes`)
- Lovable Cloud: non più backend operativo di CrAPP
- Vecchio Project Ref `hetycilxgkdmccelwerq`: deprecato, non utilizzare
---
## Database
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca
schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche,
route aggiornate) non è ancora deployato: la tabella si riempie già in background
- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna
`giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge
l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
in `npx supabase migration list` del 24/09/2026)
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
da `/admin` dopo il deploy del codice nuovo
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
bisogno di una migration
- `public.giocatori_squadra` è ora la source of truth della rosa letta dall'app (DD-015,
03/09/2026): «Aggiungi giocatore» e «Disattiva giocatore» della dashboard admin si
riflettono su Squadra, Presenze, Pagelle, Badge e Scout. `src/lib/crapp-data.ts` resta
solo come seed storico, fallback offline e sorgente della data di nascita (colonna non
ancora presente su `giocatori_squadra`)
- Migration `m6_avatar_giocatori`: bucket pubblico `avatar-giocatori` per le foto profilo,
al posto di `localStorage` (una per giocatore, letto da `src/lib/avatar-store.ts`)
- Migration `m10_azzera_turni_palloni_allenamenti`: toglie i turni palloni salvati sugli
allenamenti, che non ricevono più una proposta automatica (vedi
[docs/modules/palloni.md](docs/modules/palloni.md))
- Migration `m11_scritture_per_ruolo`: le policy di scrittura rispecchiano i permessi
dell'interfaccia (DD-023). Fino a M10 un qualsiasi utente autenticato poteva svuotare il
calendario o riscrivere il voto di un altro parlando direttamente con PostgREST; la tabella
dei permessi sta in [docs/DATABASE.md](docs/DATABASE.md) ed è verificata da
`test/integration/permessi.test.ts`
- Migration `m7_scout_partite`: nuova tabella `scout_partite` per l'archivio delle partite
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
in `localStorage`, quindi visibili a un solo dispositivo
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
pagelle anche `pagelle_chiuse = false`)
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
`scout_sessioni`, `scout_live`, `scout_partite`)
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
social legati a id Scout o CSI
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
role), se mai servisse di nuovo
---
## Moduli completati
- Squadra
- Presenze
- Badge (incluso badge social)
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
- Pagelle
- MVP
- Obiettivi di squadra
- Turno palloni (specifica in `docs/modules/palloni.md`)
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
- Notifiche
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
---
## Autenticazione e dashboard amministratore
In produzione su `main`. **Il login è l'unica via d'accesso** (31/08/2026): la selezione
libera del giocatore non esiste più, senza sessione Google si resta su `/benvenuto`, e i
permessi di amministrazione arrivano solo da `user_roles`.
**Attenzione all'ordine:** finché il provider Google è spento in Supabase, «Accedi con
Google» risponde
```
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
```
e **nessuno entra nell'app**. Vale ancora per chi allestisce un ambiente nuovo (per esempio
lo stack Supabase locale): il passo 1 qui sotto va fatto per primo.
Passaggi in ordine, nessuno dei quali è reversibile a metà. **Stato al 04/09/2026: fatti i
passaggi 1, 2, 3 e 5 (M4 applicata); il passaggio 4 è un processo continuo (7 dei 16 giocatori
attivi hanno già fatto il primo accesso).**
1. **Provider Google in Supabase** — Google Cloud Console: consent screen _External_ (scope
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità _Testing_
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
_Web application_ con redirect URI
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
secret in _Authentication → Providers → Google_. In _URL Configuration_: Site URL di
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
`[auth.external.google]` di `supabase/config.toml`, le due variabili
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
`http://127.0.0.1:54321/auth/v1/callback`.
2. **Migration M2** (`supabase db push`). È un `CREATE` puro: si può applicare in
produzione senza toccare il comportamento attuale.
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
4. **Collegamento degli account**: ciascuno accede con Google e viene collegato in
automatico al proprio giocatore per email (DD-018) — nessuna scelta manuale. Finché
l'email di un giocatore non è impostata, il suo accesso mostra un errore; da `/admin` si
imposta l'email di un giocatore (nuovo o esistente) senza bisogno di una migration. Da
settembre 2026 tutti i giocatori attivi hanno l'email registrata, ma il collegamento vero
e proprio (`auth_user_id`) avviene solo al primo login di ciascuno, quindi resta un
processo continuo che si ripete a ogni nuovo giocatore aggiunto a stagione in corso. Uno
slot già collegato può essere liberato solo da un admin.
5. **Solo a squadra collegata**: migration `m4_solo_autenticati`, che toglie al ruolo `anon`
l'accesso alle tabelle v1.0. Da lì in poi i dati sono raggiungibili solo con una sessione;
le route in `src/routes/api/public/` usano la service role e continuano a funzionare.
**Applicata in produzione il 03/09/2026** — non è più necessario aspettare che l'intera
rosa abbia già fatto login: il login era già l'unica via d'accesso lato app, quindi i
giocatori non ancora collegati non erano comunque impattati; M4 chiudeva solo un residuo
di accesso diretto al database bypassando l'app.
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione
1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
---
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
slot lo occupa anche in produzione e va liberato da un admin.
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
## Note
Il progetto segue una metodologia document-first.
Ogni nuova funzionalità viene progettata nella cartella `docs/modules/` prima di essere implementata.
Il progetto segue una metodologia document-first (DD-002): ogni funzionalità si progetta in
`docs/modules/` prima di essere implementata.
+3 -6
View File
@@ -49,12 +49,9 @@ committare lo decide chi sviluppa (DD-019).
## Variabili d'ambiente
Il progetto richiede le seguenti variabili:
- `SUPABASE_URL`
- `SUPABASE_PUBLISHABLE_KEY`
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_PUBLISHABLE_KEY`
Per far partire l'app servono `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`, `VITE_SUPABASE_URL` e
`VITE_SUPABASE_PUBLISHABLE_KEY`. L'elenco completo (service role, VAPID, SMTP del worker) sta in
[docs/OPERATIONS.md](docs/OPERATIONS.md).
## Documentazione
+42 -1
View File
@@ -85,9 +85,13 @@ quando il database non risponde.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
schermate, pochi click.
**Caratteri** (DD-049): niente testo sotto i 13 px, tranne la navigazione a pillola e i badge
da 10-11 px. Scala in uso: 13 px note, 15 px corpo, 16 px titoli di card; si usano le misure
fisse `text-[13px]`…`text-[16px]`, non `text-xs` e `text-sm`.
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
@@ -102,6 +106,43 @@ Il movimento usa molle interrompibili di `motion` con i preset in `src/lib/molla
lanciato, così swipe come quello del calendario atterrano dove il gesto stava andando.
`src/lib/motion.ts` conserva solo il rilevamento del movimento ridotto e i coriandoli.
## Trappole note
Cose che si scoprono solo rompendole; ognuna ha la sua decisione o il suo documento.
- **Non invalidare la cache**: dopo una mutazione si usa `setQueryData`, non `invalidateQueries`
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
- **Niente database dai componenti**: solo tramite `src/lib/` (DD-013).
- **Tabelle presenti ma non usate**: `giocatori`, `eventi`, `presenze` (DD-014) e `promemoria_push`.
Il modello in uso è `giocatori_squadra`, `eventi_app`, `risposte_presenze`.
- **Route in `src/routes/api/public/`**: usano la service role e saltano la RLS, quindi chiedono
le credenziali (DD-024). Una nuova route che avvisa la squadra deve farlo.
- **`convocati` vuoto significa tutta la rosa**, non nessuno (DD-027).
- **L'allenatore non è un giocatore**: è fuori da `inRosa()`, non vota e non riceve i promemoria
(DD-034, DD-039).
- **Cancellare un evento** pulisce a cascata le tabelle collegate; una tabella nuova legata
agli eventi va aggiunta al trigger (DD-029).
- **Dev e produzione condividono lo stesso Supabase**: le prove distruttive si fanno sullo stack
locale ([OPERATIONS.md](OPERATIONS.md)). Il seed gira solo in locale.
- **Migration**: mai riscrivere una già applicata; un `CHECK` sul tipo di `notifiche_utente` va
esteso con una migration nuova quando nasce un tipo nuovo.
- **Dipendenze**: `bunfig.toml` impone `minimumReleaseAge = 24h`; escluderne un pacchetto richiede
conferma dell'utente.
## Aggiungere un modulo o una funzione
Nell'ordine, perché il progetto è document-first (DD-002):
1. scheda in `docs/modules/<nome>.md` (stato, file principali, obiettivo, dati, permessi) e riga in
[ROADMAP.md](ROADMAP.md); se la scelta è non ovvia, una `DD-XXX` in [decisions/](decisions/README.md);
2. se serve lo schema: tabella documentata in [DATABASE.md](DATABASE.md), **nuova** migration,
permessi di scrittura nella sua tabella e test in `test/integration/permessi.test.ts`;
3. logica pura in `src/lib/<nome>-core.ts` con test in `test/unit/`; hook e accesso ai dati in
`src/lib/<nome>.ts`; componenti in `src/components/crapp/`, riusando quelli esistenti;
4. se tocca le notifiche: il catalogo in [modules/notifiche.md](modules/notifiche.md);
5. voce in [CHANGELOG.md](CHANGELOG.md) sotto `[Non rilasciato]` e checklist «Fine lavoro» di
[AGENTS.md](../AGENTS.md).
## Comandi
```bash
+252
View File
@@ -13,6 +13,258 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
26/10/2026.
### Aggiunto
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
Nessuna migration.
### Modificato
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
### Corretto
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
vuoto resta «tutta la rosa».
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
eventi a cui non sei convocato.
## [1.4.0] - 2026-10-09
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
### Aggiunto
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
## [1.3.1] - 2026-10-06
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
### Modificato
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
(«Nessun compleanno questo mese»).
### Rimosso
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
## [1.3.0] - 2026-10-06
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
e le presenze si contano solo sugli eventi a cui si è convocati. In tutta l'app i caratteri sono più grandi e gli
avvisi in Home più chiari. La migration `m31_notifica_compleanno` è **da applicare in produzione** (`npx supabase
db push`); il resto non tocca il database.
### Aggiunto
- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri
attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo
messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration
`m31_notifica_compleanno`, da applicare in produzione.
- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che
spiega tutto: come si calcola, cosa conta e cosa no, cosa è una presenza persa, periodo di validità, fonte dei dati,
un esempio con numeri. Nessuna migration.
### Modificato
- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se
non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Assente, forse,
infortunato e nessuna risposta restano presenze perse. Nessuna migration.
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. Nessuna migration.
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
- **Avvisi in Home più leggibili** — certificati e turno palloni usano lo stesso componente (`Avviso`): icona
propria per ogni caso (non solo il colore), titolo e testo più grandi, elenchi di persone in righe con il dettaglio
a destra e il conteggio, freccia dove la card porta ai documenti, comparsa graduale. Il turno palloni ha la pillola
«Oggi»/«In arrivo» e i messaggi in riquadri. Solo aspetto: testi e regole di quando compaiono non cambiano.
Nessuna migration.
- **Caratteri più grandi in tutta l'app** — ogni testo sotto i 16 px sale di un passo (12→13, 14→15, ecc.), nelle
card, negli elenchi, nelle statistiche e nei drawer. Restano uguali le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge minuscoli. Solo aspetto, nessuna migration (DD-049).
- **Etichetta «Cacche» nella rosa** — nella scheda del giocatore in Squadra la riga «Cacche/partita» si chiama
«Cacche»; il valore resta la media per partita. Nessuna migration.
### Rimosso
- **Tessera CSI dalla dashboard** — in Profili spariscono il blocco «Tesseramento CSI» (numero e data
tessera), il badge «Tesserato»/«Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non
cambia. Le colonne `numero_tessera` e `data_tessera` restano a database, non più usate (DD-048). Nessuna migration.
## [1.2.3] - 2026-10-04
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
### Modificato
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
### Sicurezza
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
## [1.2.2] - 2026-10-01
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
promemoria degli eventi non c'è più, la foto tessera è stata tolta e l'avviso del certificato medico arriva al
giocatore un mese prima. Le migration `m27`–`m30` sono applicate in produzione dal 01/10/2026; questa versione porta il
codice dell'app che le accompagna, da rilasciare subito: la `m29` ha tolto la colonna `foto_path` che la versione
precedente ancora legge.
### Rimosso
- **Foto tessera del profilo** — il giocatore non la carica più e l'admin non la vede nella scheda; il
completamento del profilo si calcola su dati personali (34%), documento (33%) e certificato (33%). Migration
`m29_senza_foto_tessera` (elimina la colonna `foto_path`); i file già caricati sono stati cancellati dal
bucket `profili-giocatore` (DD-044). Applicata in produzione il 01/10/2026, con la cancellazione dei 4 file già caricati.
- **Promemoria evento a 24 e 3 ore** — non partono più per nessun evento (allenamenti, partite, extra-campo):
niente notifica, push né email. Migration `m28_niente_promemoria_evento` (tolti i due job `pg_cron`;
funzione, tipi e notifiche già generate restano). Applicata in produzione il 01/10/2026 (DD-043).
### Modificato
- **Sollecito presenze: un solo avviso automatico, 24 ore prima** — a chi non ha risposto o ha risposto
«forse»; prima ne partivano tre (24, 12 e 6 ore). Il pulsante «Sollecita» non cambia. Migration
`m30_sollecito_solo_24h`, applicata in produzione il 01/10/2026 (DD-045).
- **Turno palloni: un solo avviso, 3 ore prima** — a chi deve portare i palloni e a chi li deve
prendere; prima ne partivano tre (12, 6 e 3 ore). Migration `m27_palloni_solo_3h`, applicata in
produzione il 01/10/2026 (DD-042).
- **Avviso certificati: giocatore a 30 giorni** — il giocatore vede l'avviso giallo sul proprio
certificato medico da un mese prima della scadenza (prima 7 giorni). Per gli admin non cambia
nulla: 7 giorni prima in giallo, nero dopo la scadenza (DD-041).
## [1.2.1] - 2026-09-30
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
tutte le notifiche legate agli eventi sono più formali e completi (DD-040). La migration `m26` è applicata in
produzione dal 30/09/2026; questa versione porta il codice dell'app che la accompagna.
### Aggiunto
- **Turno palloni automatico** — 12, 6 e 3 ore prima di allenamenti e partite con un incaricato confermato,
all'incaricato («incarico assegnato») e a chi aveva i palloni all'evento precedente («riconsegna»), se è
un'altra persona. Se l'incaricato cambia, chi era stato avvisato riceve una sola volta «Turno palloni:
incarico revocato». Arriva come push e nel centro notifiche, non per email. Il pulsante «Avvisa chi è di
turno» resta.
- **Sollecito presenze automatico** — 24, 12 e 6 ore prima dell'evento, ai convocati (o a tutta la rosa
attiva se non ce ne sono) che in quel momento non hanno risposto o hanno risposto «forse», mai
all'allenatore; chi risponde smette di riceverlo. Un evento creato tardi riceve solo il sollecito della sua
fascia. Push, notifica in app ed email. Il pulsante «Sollecita» resta.
- **Migration `m26_notifiche_automatiche`** — tipi di notifica nuovi (`turno_palloni_12h`, `_6h`, `_3h`,
`turno_palloni_revocato`, `sollecita_presenze_24h`, `_12h`, `_6h`), funzioni SQL dei job e due job
`pg_cron` ogni 15 minuti (`avvisi-palloni-automatici`, `solleciti-presenze-automatici`), riservati alla
service role. Ogni notifica automatica si genera una sola volta per giocatore, evento e tipo, anche se
viene eliminata (registro `promemoria_eventi_generati`). Le fasce sono contigue: un evento creato tardi
non riceve tutte le notifiche insieme.
- **Test** — nuovo test di integrazione `notifiche-automatiche` (26 prove) sui job veri del database:
finestre, destinatari, doppioni, revoca, code push ed email, permessi, pianificazione, e confronto con i
testi e l'ordine del codice TypeScript. Nuovi test unitari di testi, destinatari, date e a capo delle
notifiche; aggiornati i test di promemoria, code push ed email, worker e route dei pulsanti.
- **Documentazione** — DD-040 in `decisions/`; sezione «Testi delle notifiche» nel catalogo di
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
### Modificato
- **Testi delle notifiche** — i promemoria a 24 e 3 ore dicono «domani» se l'evento è domani, «oggi»
altrimenti, con data, ora e luogo. Turno palloni e sollecito hanno righe etichettate (Evento, Data, Ora,
Luogo, Incarico, Azione). Stessi testi per i pulsanti e per i job. I promemoria già generati non cambiano.
- **Sollecito manuale** — il pulsante «Sollecita» ora segue i convocati dell'evento, come l'automatico,
invece di tutti i giocatori attivi; il testo dice la risposta attuale e chi lo ha chiesto. Il turno
palloni manuale non parte più per email.
- **Turno palloni manuale** — senza un incaricato per l'evento non parte più nessun avviso, nemmeno quello
di riconsegna, come già descritto nel catalogo.
- **Ordine degli eventi dei palloni** — data, poi ora, poi id (prima solo la data): «evento precedente» e
«successivo» coincidono con quelli dei job del database.
- **Centro notifiche** — le righe del corpo di una notifica vanno a capo.
- Il sollecito e il turno palloni non sono più descritti come «solo manuali» nel catalogo delle notifiche.
### Corretto
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
## [1.2.0] - 2026-09-29
Le notifiche arrivano su tre canali (push, centro notifiche in-app ed email) e non si ripetono più.
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.
### Aggiunto
- **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.
### Modificato
- **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.
### Corretto
- **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.
## [1.1.1] - 2026-09-28
### Aggiunto
+119 -39
View File
@@ -9,8 +9,8 @@ non in questo file.
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
nessuna di esse da M4 (DD-011).
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -19,6 +19,11 @@ nessuna di esse da M4 (DD-011).
| `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) |
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
@@ -26,63 +31,138 @@ nessuna di esse da M4 (DD-011).
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## Lettura
Regola generale: ogni autenticato legge. Le eccezioni sono tutte dati personali o code interne:
| Tabella / bucket | Chi legge |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `profili_giocatore` | il titolare e gli admin (contiene documento d'identità e certificato medico, DD-016) |
| `bucket profili-giocatore` | privato: il titolare la propria cartella, l'admin tutto via signed URL a scadenza breve |
| `notifiche_utente` | il destinatario, solo le proprie righe (RLS, DD-030) |
| `preferenze_utente` | l'utente, solo la propria riga |
| `notifiche_email_coda`, `notifiche_push_coda`, `promemoria_eventi_generati` | nessuno da client: RLS attiva senza policy, solo service role |
| `giocatori_squadra.nascita` | tutta la rosa attiva: è una colonna pubblica sincronizzata, non una lettura di `profili_giocatore` (DD-031) |
## Anagrafica e utenti
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra: source of truth della rosa (DD-015). | ID testuali (`g1`…`gN`). Dettaglio colonne qui sotto. Letta con `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`; `src/lib/crapp-data.ts` resta solo come seed e fallback (`rosaFallback()`). |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento, certificato medico e path dei file; 1:1 con `giocatori_squadra`. | Letta da `src/lib/profili.ts` (DD-016). I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (`admin`, `allenatore`, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano (vedi [OPERATIONS.md](OPERATIONS.md)). `allenatore` non si inserisce a mano: lo gestisce il trigger `sincronizza_ruolo_allenatore` (DD-034). |
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
Colonne di `giocatori_squadra` che meritano una nota:
| Colonna | Significato |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
| `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. |
`giocatori_squadra` è usata da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
## Storage
| Bucket | Scopo | Note |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m2_profili_giocatore`. |
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
| Bucket | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità e certificato medico, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato e destinato a restare tale**: documenti e dati sanitari non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella; l'admin scarica tutto con signed URL a scadenza breve. |
| `avatar-giocatori` | Foto profilo (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali. Qualsiasi autenticato può caricare, sostituire o eliminare un file (nessun controllo per proprietario: la maggior parte dei giocatori non ha ancora `auth_user_id`, DD-018). Letto da `src/lib/avatar-store.ts`. |
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
| Tabella | Scopo | Note |
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). |
| Tabella | Scopo | Note |
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. |
## Votazioni
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. |
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. |
## Turni e notifiche
| Tabella | Scopo | Note |
| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. |
| Tabella | Scopo | Note |
| ---------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Turni palloni **confermati**. | Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)). |
| `push_subscriptions` | Dispositivi registrati per le notifiche push. | |
| `promemoria_push` | **Non più usata.** | Serviva da coda del testo quando la push partiva vuota (DD-026). Da eliminare con una migration. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto. | Vedi sotto. Letta e segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), solo via RLS. |
| `notifiche_email_coda` | Coda delle email: una riga per notifica, con stato d'invio. | Vedi sotto. Specifica in [notifiche-email.md](modules/notifiche-email.md). |
| `notifiche_push_coda` | Coda delle push dei promemoria: una riga per notifica, con stato. | Vedi sotto. |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Chiave `auth_user_id`. Oggi una colonna, `email_notifiche` (default `true`). **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore in Profilo → Opzioni (`useEmailNotifiche()`). |
| `promemoria_eventi_generati` | Registro dei promemoria già generati, per giocatore, evento e tipo. | Chiave `(giocatore_id, evento_id, tipo)`, `ON DELETE CASCADE` verso giocatore ed evento (DD-029, DD-037). Eliminare la notifica non fa rigenerare il promemoria. È anche la memoria che permette la revoca (DD-040). |
## Funzioni speciali
### `notifiche_utente`
| Tabella | Scopo | Note |
| ---------------- | --------------------- | -------------------------------------- |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
- Sorgenti: messaggio libero dell'admin, promemoria evento automatici, turno palloni, sollecito
presenze e sondaggio pre-partita (`sondaggio_cacche`). Dettaglio e testi in
[modules/notifiche.md](modules/notifiche.md).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
Stessa struttura: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`; stati
`in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo`
(backoff), `errore` (solo codici SMTP per l'email, HTTP per la push), `inviata_il`, `aggiornata_il`.
- Sono riempite da trigger su `notifiche_utente` (`accoda_notifica_email`, `accoda_push_promemoria`)
per le notifiche create dopo le rispettive migration, senza backfill.
- Le usa solo il worker `mailer/` con la service role, tramite funzioni `SECURITY DEFINER`
riservate a `service_role`: `prendi_notifiche_email(p_max)`, `esito_notifica_email(...)`,
`email_inviate_ultime_24h()`, `prendi_push_promemoria(p_max)`, `esito_push_promemoria(...)`.
I lotti usano `FOR UPDATE SKIP LOCKED` e recuperano le righe ferme in `in_invio` da più di 10 minuti.
- RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`.
- Sono tabelle separate da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga
gli permetterebbe di riportare a `in_coda` una mail già partita (DD-036).
- Il worker salta gli slot senza account o email, o con le email spente; per le push salta chi non
ha dispositivi e i promemoria creati da oltre 3 ore.
### Funzioni SQL e job delle notifiche
Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano funzioni `SECURITY DEFINER`
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -19,7 +19,7 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
letto.
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
6. **Push solo per eventi importanti**: convocazioni, turno
palloni, esito finale.
7. **Niente funzionalità pesanti**: foto, video, chat.
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
+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 e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
| **Scout / Scout Live** | Rilevazione in tempo reale delle azioni di una partita, con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
| **Tre canali** | Ogni notifica può uscire come in-app, push ed email (DD-038). Email e push dei promemoria partono dal **worker** (`mailer/`). |
| **Worker** | Il container Docker in `mailer/` che invia email e push dei promemoria con la service role ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| **Service role** | Chiave Supabase che bypassa la RLS. Solo server e worker, mai il client ([OPERATIONS.md](OPERATIONS.md)). |
| **DD-XXX** | Decisione registrata in [decisions/](decisions/README.md). |
| **M1…M27** | Numero d'ordine delle migration (`m27_palloni_solo_3h`…) in `supabase/migrations/`. |
+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.
+10 -9
View File
@@ -5,15 +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. |
| `@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
+21 -15
View File
@@ -2,28 +2,34 @@
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
ancora e va **prima documentata** (vedi [DD-002](decisions/DD-002.md)).
## Dove sta cosa
| Documento | Risponde a |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
| Documento | Risponde a |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [decisions/](decisions/README.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [GLOSSARIO.md](GLOSSARIO.md) | Cosa significano i termini del dominio (slot, rosa, convocati, CSI, pagelle…) |
| [OPERATIONS.md](OPERATIONS.md) | Ambienti, variabili d'ambiente, migration, rilascio e allestimento di un ambiente nuovo (login Google, primo admin) |
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [archive/](archive/README.md) | Materiale storico non più in uso: non è fonte di verità |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md) (una pagina: a che punto
siamo, non la cronologia).
## Ordine di lettura
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
→ `DATABASE.md` → `DESIGN_DECISIONS.md` → il documento del modulo interessato in `modules/`.
→ `DATABASE.md` → il documento del modulo interessato in `modules/` → solo le decisioni
(`decisions/DD-XXX.md`) che il modulo o l'indice richiamano, non tutte.
## Regole di manutenzione
@@ -36,8 +42,8 @@ Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
una si copia [\_template-dd.md](_template-dd.md).
- le motivazioni stanno solo in `decisions/`, un file `DD-XXX.md` per decisione; per
aggiungerne una si copia [\_template-dd.md](_template-dd.md) e si aggiorna l'indice.
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
+16 -6
View File
@@ -27,10 +27,10 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
@@ -38,9 +38,19 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la
scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036)
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
## Prossimo
+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. |
+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.
+65
View File
@@ -0,0 +1,65 @@
# DD-047 — Notifica di compleanno
**Data:** 6 ottobre 2026
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
**Contesto**
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
**Decisione**
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
`notifiche_utente`, il worker `mailer/` manda push ed email.
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
mancato si recupera all'ora dopo e non si genera mai due volte.
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
nessun avviso.
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
senza i nomi degli altri.
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
`eventi_app`.
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
**Alternative scartate**
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
chiede la issue.
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
**Conseguenze**
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
destinatari). Chi la inserisce prima delle 8 viene incluso.
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
- Il compleanno resta anche in Calendario, invariato.
**Riesame**
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
+37
View File
@@ -0,0 +1,37 @@
# DD-048 — Via numero e data tessera dalla dashboard
**Data:** 6 ottobre 2026
**Stato:** Accettata (nessuna migration)
**Contesto**
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
**Decisione**
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
amministrativo: documento, certificato, dati personali.
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
già inseriti restano a database.
**Alternative scartate**
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
già inseriti; si può fare dopo senza toccare l'interfaccia.
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
`0/N`, un'informazione falsa.
**Conseguenze**
- L'admin non può più registrare né correggere la tessera dall'app.
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
**Riesame**
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
migration e togliere i due campi dal tipo e dalla lettura.
+53
View File
@@ -0,0 +1,53 @@
# DD-049 — Caratteri più grandi in tutta l'app
**Data:** 6 ottobre 2026
**Stato:** Accettata (solo interfaccia, nessuna migration)
**Contesto**
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
**Decisione**
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
| Prima | Dopo |
| ----------------- | ----- |
| `text-xs` (12 px) | 13 px |
| 13 px | 14 px |
| 14 px | 15 px |
| `text-sm` (14 px) | 15 px |
| 15 px | 16 px |
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
(conteggio sulla campanella).
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
l'aspetto, non i testi né le regole su quando un avviso compare.
**Alternative scartate**
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
barre a pillola, e ogni schermata andrebbe ricontrollata.
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
il testo andrebbe a capo o verrebbe troncato.
**Conseguenze**
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
titoli di card.
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
**Riesame**
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
contenitori stretti, senza toccare il resto.
+73
View File
@@ -0,0 +1,73 @@
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
**Data:** 8 ottobre 2026
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
**Contesto**
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
**Decisione**
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
evento). Scatta per **qualsiasi
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
modifica la fa un admin per conto suo.
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
ricevere notifiche, perché `notifiche_utente` è per slot.
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
notifiche e cinque email.
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
**Fase di prova (conclusa)**
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
ora vale la regola del punto 2.
**Alternative scartate**
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
**Conseguenze**
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
[allenatore.md](../modules/allenatore.md#notifiche).
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
per evento, non togliere l'avviso.
**Riesame**
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
sono troppe o poche, spostare la soglia (una costante nella funzione).
+110
View File
@@ -0,0 +1,110 @@
# Registro delle decisioni di progetto
Ogni file di questa cartella è **una decisione** (`DD-XXX.md`): una scelta che ha influito sulla
direzione del prodotto, sull'organizzazione del lavoro o su come l'app si evolve. Non descrive
_come_ è fatto il codice (per quello esistono [ARCHITECTURE.md](../ARCHITECTURE.md) e
[DATABASE.md](../DATABASE.md)): risponde a _perché abbiamo scelto così_, _cosa avevamo escluso_
e _quando conviene riaprire la scelta_.
**Leggi solo le decisioni che ti servono**: cerca il tema nell'indice qui sotto e apri il file
corrispondente. Non serve leggerle tutte.
## Come usare questo registro
Ogni decisione segue lo schema di [../_template-dd.md](../_template-dd.md):
| Campo | Significato |
| ------------------------ | ------------------------------------------------------ |
| **Data** | Quando la decisione è stata presa o confermata |
| **Stato** | Accettata · In valutazione · Sostituita · Obsoleta |
| **Contesto** | Quale problema o opportunità avevamo di fronte |
| **Decisione** | Cosa abbiamo scelto di fare |
| **Alternative scartate** | Cosa non abbiamo fatto e perché |
| **Conseguenze** | Cosa comporta nel quotidiano (utenti, admin, sviluppo) |
| **Riesame** | Quando ha senso riconsiderarla |
**Quando aggiungere una voce**
- una scelta influisce su più moduli o su più release;
- escludiamo un'alternativa non ovvia;
- accettiamo un compromesso consapevole (debito, limitazione, ritardo);
- cambiamo una decisione precedente.
**Quando non serve**
- dettagli implementativi locali;
- scelte estetiche minori;
- bugfix o correzioni puntuali.
**Come registrare una nuova decisione**
Copiare [../_template-dd.md](../_template-dd.md) in `docs/decisions/DD-XXX.md` con il primo ID
libero (gli ID non si riusano e non si rinumerano), poi aggiungere la riga all'indice qui sotto.
Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimanda a quella nuova.
## Indice
**Accettate**
| ID | Titolo |
| ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione**
| ID | Titolo |
| ------------------- | --------------------- |
| [DD-014](DD-014.md) | Convergenza schema DB |
**Sostituite**
| ID | Titolo |
| ------------------- | --------------------- |
| [DD-003](DD-003.md) | Branch main / develop |
+56 -35
View File
@@ -1,7 +1,7 @@
# Modulo — Ruolo Allenatore
**Stato:** implementato
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
**Decisione:** [DD-034](../decisions/DD-034.md)
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
@@ -55,8 +55,8 @@ Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
| Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto
tessera, tesseramento CSI.
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico,
tesseramento CSI.
### Completamento profilo
@@ -97,7 +97,7 @@ nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
@@ -114,7 +114,7 @@ a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso.
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono un sollecito automatico 24 ore prima dell'evento, ma il pulsante resta.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
@@ -122,47 +122,67 @@ risposte presenze altrui, gestione dei ruoli.
## Notifiche
L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche
in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()`
lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio
Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che
riguardano chi gioca.
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. |
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
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 })`), i destinatari dei promemoria evento e dei
messaggi admin, che lo includono.
(`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` |
| 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) |
| 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, foto tessera | i propri | — | lettura/download |
| Avviso certificati in Home | il proprio | no | tutta la rosa |
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
| Sollecitare le presenze | no | sì | sì |
| Promemoria palloni, notifica personalizzata | no | no | sì |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
| 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
@@ -174,7 +194,7 @@ giocatore).
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti.
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.
@@ -183,7 +203,8 @@ giocatore).
## Scelte confermate
- Riceve i promemoria degli eventi: sì.
- 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
+34 -266
View File
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
@@ -93,7 +93,6 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati)
@@ -104,13 +103,12 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
| id | nome | condizione esatta |
| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
@@ -140,259 +138,31 @@ voti ricevuti).
## Copertura test
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
end-to-end completa.
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
`badges.test.ts`) sull'output di altri moduli:
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo". Più la soglia minima di voti (vedi sotto).
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
tabella.
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
gli altri badge da tabella.
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
voto/turni/presenze
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
copertura di questo badge):
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
(`mvp_no_autovoto`).
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
votato non convocati vengono rifiutati.
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
deve contare come vittoria.
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
(`pagelle_no_autovoto`), già presente prima di questa sessione.
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
applichi le soglie normali sopra soglia.
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
pur avendo già una conferma.
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
M13 — vedi sotto):
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
filtro sui convocati, solo partite/allenamenti).
- Integration (`npx supabase start` richiesto):
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
scritta per un evento senza convocazione **non conta comunque**, perché
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
funzione pura che il badge consuma, non solo in UI.
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
pura.
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
convocati che non spezzano la serie di chi non era coinvolto).
- Integration (`npx supabase start` richiesto):
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
non riparte da 1).
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
database, non possono garantire.
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
azzera), e un evento futuro che non entra ancora nel calcolo.
- Integration (`npx supabase start` richiesto):
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
- Integration (`npx supabase start` richiesto):
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
esattamente al confine (3 infortuni, 5 ritardi).
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
`cacche.test.ts` (già completo su `giornateTop`).
- Integration (`npx supabase start` richiesto):
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
- Integration (`npx supabase start` richiesto):
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
`mvpVintiPerGiocatore()`/`mediePagelle()` → `statoBadge()` che userebbe l'app.
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
segreto a combinare due statistiche indipendenti (`serieConferme()` e
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
bastano".
- Integration (`npx supabase start` richiesto):
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
già accumulate (le due statistiche restano indipendenti anche a database).
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
meccanismo:
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
vince in `m1` e `m2` → `{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
mischiano in `badgeSocialVinti()`.
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
tocca il conteggio delle altre 4 nella stessa partita.
Tutti e 16 i badge hanno test unit (`test/unit/badges.test.ts`, `badge-social.test.ts`) e di
integration end-to-end sul database locale (`test/integration/*-badge.test.ts`,
`badge-social.test.ts`, più `scritture` e `permessi`). La logica pura sta in `badges.ts`; i test
verificano soglie inclusive (1→bronzo, soglia decimale 6.49 ≠ 6.5), confini dei badge segreti e
la soglia minima di voti della Pagella (`VOTI_MINIMI_PAGELLA`). Il dettaglio test per test lo
dicono i file, non questa pagina: leggili prima di cambiare una soglia.
### Riepilogo per badge
| # | id | tipo | test unit | test integration |
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
| # | id | tipo | test unit | test integration |
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
---
@@ -419,11 +189,9 @@ meccanismo:
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
rimossi il 2026-10-06.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
@@ -439,7 +207,7 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
**Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
@@ -464,7 +232,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
## Evoluzioni possibili
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
- Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
+16 -5
View File
@@ -24,9 +24,13 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
selezionato.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
@@ -39,7 +43,7 @@ true })` — ci sono anche gli allenatori, DD-034 — non righe
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
([DD-033](../decisions/DD-033.md)).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
@@ -49,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
luogo, e né la card né le notifiche lo mostrano.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
@@ -68,10 +79,10 @@ dalle risposte solo sulla scheda evento (vedi `presenze.md`).
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
[DD-029](../decisions/DD-029.md))
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
orfane nel database (bonificate una tantum da M15/M16, vedi [DATABASE.md](../DATABASE.md)).
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
+22 -22
View File
@@ -31,14 +31,14 @@ un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| 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 |
| --------------------- | -------------------------------------- |
| 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 |
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
@@ -50,7 +50,7 @@ mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione s
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
a 2026/27 ([DD-032](../decisions/DD-032.md)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
@@ -61,11 +61,11 @@ verificato chiamando l'endpoint direttamente, che oggi restituisce sia
### 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`) |
| 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).
@@ -104,11 +104,11 @@ Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `mat
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 |
| 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
@@ -262,7 +262,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
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
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
@@ -289,7 +289,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
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
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
@@ -312,7 +312,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
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
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
+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, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **Fa eccezione il turno palloni** (DD-040, dalla
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
**non** vengono inviate: il trigger non ha backfill.
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`).
+222 -11
View File
@@ -14,7 +14,8 @@ centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
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, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
@@ -25,6 +26,160 @@ particolare il certificato in scadenza **non** genera push né voci nel centro n
---
## 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 |
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
Le notifiche generate dal server (righe 1-6, 8 e 9) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
automatiche (4, 5, 8 e 9; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»).
> **Righe 1, 2, 4 e 5 (DD-040).** Implementate con la migration `m26` (job `pg_cron`, testi, revoca) e
> applicate in produzione (`m26` il 30/09/2026; `m27` il 01/10/2026, che porta il turno palloni a un solo
> avviso 3 ore prima, DD-042).
Cosa riceve l'allenatore, notifica per notifica: [allenatore.md](allenatore.md#notifiche).
### Testi delle notifiche
Il testo è lo stesso su push, centro notifiche in-app ed email: titolo e corpo si scrivono una
volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato di
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
| # | Notifica | Titolo | Corpo |
| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
dall'emoji. Non entrano nel centro notifiche e non generano email.
| Situazione | Titolo | Corpo |
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento. 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, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047).
### Comportamenti da conoscere
Sono l'effetto attuale della logica, non scelte documentate altrove. 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.
- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e
agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se
il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario.
Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro.
- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli
altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato.
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
### Test che verificano il catalogo
| Cosa | Test |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
codice; l'unico pezzo puro, `inRosa`, è coperto.
---
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
@@ -62,14 +217,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
@@ -93,6 +249,9 @@ variabile d'ambiente.
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
anche per l'allenatore, `richiediGestoreEventi()`).
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
@@ -117,6 +276,13 @@ 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)
@@ -131,27 +297,35 @@ non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico per
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Quattro sorgenti scrivono in `notifiche_utente`, mai il client:
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
job `pg_cron` come la 2, che scrive `turno_palloni_3h`/`_revocato` (`_12h`/`_6h` solo fino a `m26`) e `sollecita_presenze_24h` (`_12h`/`_6h` solo fino a `m29`); i pulsanti restano e scrivono `turno_palloni` e `sollecita_presenze`):
1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario
in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso).
2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
2. **Promemoria evento (rimosso dalla `m28`, DD-043: i job non esistono più)** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo
`UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda
doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
(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 e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)`
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
@@ -164,6 +338,43 @@ sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla
× 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
+56 -27
View File
@@ -2,7 +2,8 @@
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`),
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
---
@@ -24,7 +25,7 @@ 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
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.
@@ -37,22 +38,27 @@ corrente dinamico (vedi sotto).
## Obiettivi definiti
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
primo obiettivo non completato).
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
@@ -65,7 +71,22 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
seguono di conseguenza.
seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono
presenze perse.
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
`adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a
0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il
passaggio a completato compare al refresh successivo.
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i
convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione
`percentualePresenze()`. Non c'è un confine di stagione esplicito: conta ciò
che c'è in `eventi_app`.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
@@ -104,7 +125,7 @@ regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocat
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
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: []`.
@@ -144,21 +165,29 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ------------ | -------------------------------- | ------------------------------------------------------------ |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
| Obiettivi | Unit test | Integration test |
| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti
contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa,
confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia
del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test
diventa rosso.
- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi,
«Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato
in corso/completato.
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
(evento dentro/fuori mese), scadenza dinamica.
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
+13 -5
View File
@@ -10,8 +10,8 @@
## Obiettivo
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
notifiche e senza email 3 ore prima dell'evento (DD-040, DD-042). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
compleanni non c'entrano: non richiedono palloni.
---
@@ -26,7 +26,7 @@ compaiono.
## Implementazione
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
scarta eventi extra-campo e compleanni.
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
@@ -55,6 +55,13 @@ oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un al
## Route API pubblica `/api/public/promemoria-palloni`
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
> `pg_cron` ogni 15 minuti manda l'avviso **una sola volta, 3 ore prima** dell'evento (DD-042, migration `m27`), solo se per l'evento c'è un
> incaricato confermato in `turni_palloni`. Il pulsante e la route **restano**, con tipo
> `turno_palloni` e push dalla route. I due destinatari e i testi nuovi («incarico assegnato» /
> «riconsegna», validi per il pulsante e per i job, più la revoca se l'incaricato cambia) sono nel
> [catalogo](notifiche.md#testi-delle-notifiche). Il resto della sezione descrive il pulsante.
La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro
palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`,
e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i
@@ -69,8 +76,9 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
## Limiti noti
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
- **L'avviso automatico parte solo con un incaricato confermato** (DD-040): per una partita la
proposta di `completaTurni()` non salvata non basta, e senza incaricato non parte nulla. Il pulsante
dell'admin (DD-025) resta per rimandare l'avviso o per una proposta non ancora confermata. `destinatariPromemoriaPalloni()` — la versione "chi è di
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
"assente" o "infortunato" per quell'evento.
+13 -5
View File
@@ -68,14 +68,22 @@ compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
↓
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
├─ destinatariSollecito() → src/lib/presenze.ts
│ (attivi senza risposta o con "forse"; funzione pura, testata in unit)
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **una sola volta, 24 ore prima** dell'evento (DD-045, migration `m30`) a chi in
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
@@ -86,9 +94,9 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito.
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
solo dal bottone admin, e la route verifica il ruolo lato server con `richiediAdmin`
(DD-024).
- Il sollecito ha due vie (DD-040): un sollecito **automatico** (24 ore prima, job `pg_cron`
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
+52 -50
View File
@@ -44,7 +44,7 @@ Può:
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
Non può caricare o sostituire i file altrui: documento e certificato restano
responsabilità del giocatore che li fornisce.
## Flusso utente
@@ -73,7 +73,6 @@ Viene mostrata una barra di avanzamento (esempio: _Profilo completato — 85%_),
- Dati personali
- Documento di identità
- Certificato medico
- Foto tessera
Quando tutte le sezioni sono complete il widget scompare automaticamente.
@@ -82,7 +81,7 @@ sulla tab Stagione.
### Avviso certificati
**Stato:** implementato (DD-035).
**Stato:** implementato (DD-035, soglia del giocatore DD-041).
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
@@ -90,13 +89,13 @@ già salvata: niente tabella, niente migration, niente push.
#### Chi lo vede
| Utente | Cosa vede | Al tocco |
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
| Altri giocatori | niente | — |
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
| Utente | Cosa vede | Al tocco |
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------- |
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
| Admin che è anche giocatore | **entrambi**: lo staff (senza il suo nome) e il proprio personale, a 30 giorni | personale: apre `/profilo?tab=documenti` |
| Altri giocatori | niente | — |
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
@@ -109,21 +108,32 @@ solo proprio a un giocatore.
#### Quando compare
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
`GIORNI_AVVISO_CERTIFICATO = 7`.
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
staff.
| Condizione | Stato | Avviso |
| -------------------- | ----------- | ------------------ |
| `giorni > 7` | valido | nessuno |
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno |
| Chi guarda | Soglia |
| ---------- | ------------------------------------------ |
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
| Condizione | Stato | Avviso |
| --------------------- | ----------- | ------------------------------ |
| `giorni > soglia` | valido | nessuno |
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | rosso (`destructive`) (DD-046) |
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
tab Profili di `/admin` e nel widget di completamento del giocatore.
- Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello
scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti),
l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio
nome. Non ha soglie: resta finché il file non è caricato.
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
cioè `attivo` e `tipo = 'giocatore'`).
@@ -136,7 +146,7 @@ solo proprio a un giocatore.
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori
@@ -182,14 +192,16 @@ la Home non deve rompersi per un dato accessorio.
#### Implementazione
| Pezzo | Ruolo |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
| Pezzo | Ruolo |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) |
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
@@ -197,7 +209,7 @@ la Home non deve rompersi per un dato accessorio.
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili.
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
- Test in `test/unit/profili-core.test.ts`: avvisi per utente (admin giocatore, admin puro, giocatore semplice), soglia dello staff (8 giorni no, 7 sì, 0 sì, −1 scaduto) e del giocatore (31 no, 30 sì),
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
di anno nel calcolo dei giorni.
@@ -254,17 +266,16 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file.
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
riceve un avviso nella propria Home e compare in quello degli admin (vedi
Quando mancano 30 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
riceve un avviso nella propria Home; compare in quello degli admin da 7 giorni prima (vedi
[Avviso certificati](#avviso-certificati)).
Lo storico non viene mantenuto nella prima versione.
### Foto tessera
Upload di una fotografia formato tessera.
Utilizzata dagli amministratori per il tesseramento CSI.
**Rimossa** (DD-044, migration `m29`): il profilo non chiede più la foto tessera e la colonna
`foto_path` non esiste più. I file già caricati sono stati cancellati dal bucket.
### Statistiche
@@ -317,17 +328,13 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Foto tessera
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
- Scarica certificato
- Scarica documento
- Scarica foto tessera
- Modifica dati squadra e dati personali del giocatore (DD-017)
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
- Scollega account, per liberare uno slot assegnato per errore
- Aggiungi giocatore, per inserire un nuovo membro della squadra
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
@@ -353,12 +360,9 @@ Campi esportati.
## Tracciamento tesseramento
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
complessivo della squadra.
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
## Completamento profilo
@@ -366,10 +370,9 @@ Ogni sezione contribuisce alla percentuale di completamento.
| Sezione | Peso |
| --------------------- | ---- |
| Dati personali | 30% |
| Documento di identità | 30% |
| Certificato medico | 30% |
| Foto tessera | 10% |
| Dati personali | 34% |
| Documento di identità | 33% |
| Certificato medico | 33% |
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
@@ -391,7 +394,6 @@ nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
- Gestione dati personali
- Documento di identità
- Certificato medico
- Foto tessera
- Dashboard amministratore
- Esportazione CSV CSI
+18 -37
View File
@@ -1,12 +1,12 @@
# Modulo — Serie di presenze
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
**Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
`test/integration/serie-allenamenti-badge.test.ts`
---
@@ -19,15 +19,18 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
---
## Le tre serie in sintesi
## Le due serie in sintesi
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
| ------------- | ------------------ | -------------------------------------------- | ------------ |
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
restano nel database, ma oggi nessuna statistica li usa.
Esiste un terzo contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
`src/routes/profilo.tsx`.
@@ -67,10 +70,10 @@ il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
| Colonna | Cosa registra | Chi la usa |
| --------------- | --------------------- | ----------------------- |
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
| Colonna | Cosa registra | Chi la usa |
| --------------- | --------------------- | -------------------------------------- |
| `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
@@ -147,26 +150,6 @@ Conseguenze da conoscere prima di cambiare qualcosa:
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
### `serieConferme()` — conferme entro 24 ore
```ts
serieConferme(giocatoreId, eventi, tempi, oggi?)
```
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
sulla reattività, non sulla presenza.
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
l'istante giusto da confrontare.
### Lettura e cache
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
@@ -263,8 +246,6 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
| Dove | Cosa | Soglie |
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
---
@@ -320,8 +301,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
italiana potevano non essere d'accordo su cosa fosse "oggi".
+10 -11
View File
@@ -12,7 +12,7 @@
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
Prima di [DD-015](../decisions/DD-015.md)
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
@@ -48,24 +48,23 @@ gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)):
| Azione | Hook | Effetto |
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
| Azione | Hook | Effetto |
| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
([DD-018](../decisions/DD-018.md)).
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
dell'admin.
+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=="],
}
}
+7
View File
@@ -0,0 +1,7 @@
[install]
saveTextLockfile = true
# 24h supply-chain guard: skip package versions published less than a day ago.
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = []
+18
View File
@@ -0,0 +1,18 @@
services:
mailer:
build:
context: ..
dockerfile: mailer/Dockerfile
restart: unless-stopped
env_file: .env
healthcheck:
test: ["CMD", "bun", "healthcheck.mjs"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
+11
View File
@@ -0,0 +1,11 @@
// Healthcheck del container: sano se l'ultimo ciclo riuscito è recente (vedi mailer.mjs).
import { readFile } from "node:fs/promises";
import { staSano } from "./mailer-core.ts";
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
const MAX_MS = 5 * 60_000;
const ultimo = await readFile(FILE_SALUTE, "utf8")
.then((t) => Number(t))
.catch(() => null);
process.exit(staSano(Number.isFinite(ultimo) ? ultimo : null, Date.now(), MAX_MS) ? 0 : 1);
+481
View File
@@ -0,0 +1,481 @@
/**
* Logica pura del worker email (M22, DD-036), separata da rete e SMTP per poterla testare:
* configurazione, classificazione degli errori SMTP, backoff, composizione della mail e il
* ciclo di invio con le dipendenze iniettate. `mailer.mjs` fa solo da colla.
*/
/** Riga restituita da `prendi_notifiche_email()`. */
export type NotificaEmail = {
id_notifica: string;
destinatario: string;
oggetto: string;
testo: string;
id_evento: string | null;
tipo_notifica: string;
tentativi_fatti: number;
};
export type EsitoInvio = "inviata" | "riprova" | "differita" | "fallita";
export type MailComposta = {
to: string;
subject: string;
text: string;
html: string;
messageId: string;
};
// --- Configurazione ---------------------------------------------------------------------------
export type Config = {
supabaseUrl: string;
serviceRoleKey: string;
smtp: {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string } | null;
};
from: string;
fromName: string;
appUrl: string;
/** Chiavi VAPID per la push dei promemoria (M24); `null` = solo email. */
vapid: { publicKey: string; privateKey: string; subject: string | null } | null;
pollSecondi: number;
limiteGiorno: number;
lotto: number;
};
export type LetturaConfig = { config: Config } | { errori: string[] };
/**
* Legge la configurazione dalle variabili d'ambiente. Torna tutti gli errori insieme (mancanti
* e non validi) per correggere il `.env` in un solo giro. `SMTP_USER`/`SMTP_PASS` vanno o
* entrambi o nessuno dei due: senza servono solo per un server di prova locale (Mailpit).
*/
export function leggiConfig(env: Record<string, string | undefined>): LetturaConfig {
const errori: string[] = [];
const testo = (nome: string): string => (env[nome] ?? "").trim();
const obbligatoria = (nome: string): string => {
const valore = testo(nome);
if (!valore) errori.push(`${nome} mancante`);
return valore;
};
const intero = (nome: string, predefinito: number): number => {
const grezzo = testo(nome);
if (!grezzo) return predefinito;
const n = Number(grezzo);
if (!Number.isInteger(n) || n <= 0) {
errori.push(`${nome} deve essere un intero positivo`);
return predefinito;
}
return n;
};
const supabaseUrl = obbligatoria("SUPABASE_URL").replace(/\/+$/, "");
const serviceRoleKey = obbligatoria("SUPABASE_SERVICE_ROLE_KEY");
const appUrl = obbligatoria("APP_URL").replace(/\/+$/, "");
const user = testo("SMTP_USER");
const pass = testo("SMTP_PASS");
if (Boolean(user) !== Boolean(pass)) errori.push("SMTP_USER e SMTP_PASS vanno impostati insieme");
const from = testo("MAIL_FROM") || user;
if (!from) errori.push("MAIL_FROM mancante (o SMTP_USER)");
const vapidPubblica = testo("VAPID_PUBLIC_KEY");
const vapidPrivata = testo("VAPID_PRIVATE_KEY");
if (Boolean(vapidPubblica) !== Boolean(vapidPrivata)) {
errori.push("VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY vanno impostate insieme");
}
const porta = intero("SMTP_PORT", 587);
const pollSecondi = intero("POLL_SECONDI", 30);
const limiteGiorno = intero("MAIL_LIMITE_GIORNO", 400);
const lotto = intero("MAIL_LOTTO", 10);
if (errori.length > 0) return { errori };
return {
config: {
supabaseUrl,
serviceRoleKey,
smtp: {
host: testo("SMTP_HOST") || "smtp.gmail.com",
port: porta,
secure: testo("SMTP_SECURE") === "true",
auth: user ? { user, pass } : null,
},
from,
fromName: testo("MAIL_FROM_NAME") || "CrAPP",
appUrl,
vapid:
vapidPubblica && vapidPrivata
? {
publicKey: vapidPubblica,
privateKey: vapidPrivata,
subject: testo("VAPID_SUBJECT") || null,
}
: null,
pollSecondi,
limiteGiorno,
lotto,
},
};
}
// --- Errori SMTP e backoff --------------------------------------------------------------------
export type TipoErrore = "temporaneo" | "permanente" | "configurazione" | "limite";
type ErroreSmtp = { code?: unknown; responseCode?: unknown; response?: unknown; message?: unknown };
/**
* Distingue cosa fare dopo un invio fallito:
* - `limite`: tetto giornaliero di Gmail (`550 5.4.5 Daily user sending quota exceeded`, che
* sarebbe un 5xx "permanente" ma non dipende dalla mail): si rimanda senza contare un tentativo;
* - `configurazione`: credenziali SMTP rifiutate; stessa cosa, e il worker si ferma;
* - `permanente`: 5xx sul destinatario o sul messaggio, inutile riprovare;
* - `temporaneo`: 4xx e errori di rete, si riprova con backoff.
*/
export function classificaErroreSmtp(errore: unknown): TipoErrore {
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
const codice = typeof e.code === "string" ? e.code : "";
const risposta = typeof e.responseCode === "number" ? e.responseCode : 0;
const testo = `${typeof e.response === "string" ? e.response : ""} ${
typeof e.message === "string" ? e.message : ""
}`.toLowerCase();
if (/5\.4\.5|quota exceeded|sending limit/.test(testo)) return "limite";
if (codice === "EAUTH" || [530, 534, 535].includes(risposta)) return "configurazione";
if (codice === "EENVELOPE" || risposta >= 500) return "permanente";
return "temporaneo";
}
const RITARDI_MINUTI = [1, 5, 30, 120] as const;
/** Numero massimo di invii per notifica: il primo più un tentativo per ogni ritardo. */
export const MAX_TENTATIVI = RITARDI_MINUTI.length + 1;
/**
* Minuti da attendere dopo l'invio fallito numero `tentativiFatti + 1`; `null` quando i
* tentativi sono finiti e la mail va segnata come fallita.
*/
export function ritardoRiprova(tentativiFatti: number): number | null {
return RITARDI_MINUTI[tentativiFatti] ?? null;
}
/** Quante mail si possono ancora spedire rispetto al tetto delle ultime 24 ore. */
export function invioConsentiti(limiteGiorno: number, inviate24h: number, lotto: number): number {
return Math.max(0, Math.min(lotto, limiteGiorno - inviate24h));
}
/** Descrizione breve dell'errore, senza indirizzi né testi delle mail: va nei log. */
export function descriviErrore(errore: unknown): string {
const e = (typeof errore === "object" && errore !== null ? errore : {}) as ErroreSmtp;
const parti = [
typeof e.code === "string" ? e.code : "",
typeof e.responseCode === "number" ? String(e.responseCode) : "",
].filter(Boolean);
return parti.length > 0 ? parti.join(" ") : "errore sconosciuto";
}
// --- Composizione della mail ------------------------------------------------------------------
export function escapeHtml(testo: string): string {
return testo
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/** Oggetto su una riga sola (niente a-capo nelle intestazioni), con un limite di lunghezza. */
export function pulisciOggetto(titolo: string): string {
const pulito = titolo
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return (pulito || "Notifica CrAPP").slice(0, 200);
}
/** Link da aprire dalla mail: la scheda dell'evento se c'è, altrimenti la home dell'app. */
export function linkNotifica(appUrl: string, idEvento: string | null): string {
const base = appUrl.replace(/\/+$/, "");
return idEvento ? `${base}/evento/${encodeURIComponent(idEvento)}` : base;
}
/** Message-ID deterministico: lo stesso invio ripetuto porta lo stesso identificativo. */
export function messageId(idNotifica: string, dominio: string): string {
return `<${idNotifica}@${dominio}>`;
}
const AVVISO_DISATTIVAZIONE = "Non vuoi più ricevere queste email? Profilo → Opzioni → Email.";
export function componiMail(
n: NotificaEmail,
opzioni: { appUrl: string; dominioMessageId: string },
): MailComposta {
const link = linkNotifica(opzioni.appUrl, n.id_evento);
const subject = pulisciOggetto(n.oggetto);
const testo = [n.testo.trim(), `Apri CrAPP: ${link}`, AVVISO_DISATTIVAZIONE]
.filter(Boolean)
.join("\n\n");
const corpoHtml = n.testo.trim()
? `<p style="margin:0 0 16px">${escapeHtml(n.testo.trim()).replace(/\n/g, "<br>")}</p>`
: "";
const html = [
`<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:16px;line-height:1.5;color:#111;max-width:480px">`,
`<h2 style="margin:0 0 12px;font-size:18px">${escapeHtml(subject)}</h2>`,
corpoHtml,
`<p style="margin:0 0 24px"><a href="${escapeHtml(link)}" style="display:inline-block;padding:12px 20px;border-radius:12px;background:#111;color:#fff;text-decoration:none">Apri CrAPP</a></p>`,
`<p style="margin:0;font-size:12px;color:#666">${escapeHtml(AVVISO_DISATTIVAZIONE)}</p>`,
`</div>`,
].join("");
return {
to: n.destinatario,
subject,
text: testo,
html,
messageId: messageId(n.id_notifica, opzioni.dominioMessageId),
};
}
// --- Salute del container ---------------------------------------------------------------------
/** Il worker è sano se l'ultimo ciclo riuscito non è più vecchio di `maxMs`. */
export function staSano(ultimoCicloOk: number | null, adesso: number, maxMs: number): boolean {
return ultimoCicloOk !== null && adesso - ultimoCicloOk <= maxMs;
}
// --- Ciclo di invio ---------------------------------------------------------------------------
export type Dipendenze = {
prendi: (max: number) => Promise<NotificaEmail[]>;
segna: (id: string, esito: EsitoInvio, errore?: string, prossimo?: Date) => Promise<void>;
invia: (mail: MailComposta) => Promise<void>;
inviateUltime24h: () => Promise<number>;
adesso: () => Date;
log: (messaggio: string) => void;
};
export type OpzioniCiclo = {
limiteGiorno: number;
lotto: number;
appUrl: string;
dominioMessageId: string;
};
export type RisultatoCiclo = {
inviate: number;
riprovate: number;
fallite: number;
differite: number;
/** Perché il ciclo si è interrotto prima di finire il lotto, se è successo. */
fermato: "limite" | "configurazione" | null;
};
const ATTESA_LIMITE_MINUTI = 60;
const ATTESA_CONFIGURAZIONE_MINUTI = 15;
const dopoMinuti = (adesso: Date, minuti: number) => new Date(adesso.getTime() + minuti * 60_000);
/**
* Un giro: prende fino a un lotto di mail entro il tetto giornaliero e le spedisce una a una.
* I log riportano solo id, tipo ed esito: mai indirizzi né testi.
*/
export async function elaboraCiclo(d: Dipendenze, o: OpzioniCiclo): Promise<RisultatoCiclo> {
const risultato: RisultatoCiclo = {
inviate: 0,
riprovate: 0,
fallite: 0,
differite: 0,
fermato: null,
};
const consentite = invioConsentiti(o.limiteGiorno, await d.inviateUltime24h(), o.lotto);
if (consentite === 0) {
risultato.fermato = "limite";
return risultato;
}
const lotto = await d.prendi(consentite);
for (const [indice, notifica] of lotto.entries()) {
const mail = componiMail(notifica, o);
// Solo `invia` sta nel try: se fallisse la registrazione dell'esito non è un errore SMTP e
// non deve far ripartire una mail già consegnata (la riga resta 'in_invio' e viene
// recuperata dalla funzione SQL dopo 10 minuti: al peggio un doppione, mai una perdita).
let errore: unknown;
let consegnata = false;
try {
await d.invia(mail);
consegnata = true;
} catch (e) {
errore = e;
}
if (consegnata) {
await d.segna(notifica.id_notifica, "inviata");
risultato.inviate += 1;
d.log(`inviata ${notifica.id_notifica} (${notifica.tipo_notifica})`);
continue;
}
const tipo = classificaErroreSmtp(errore);
const motivo = descriviErrore(errore);
if (tipo === "limite" || tipo === "configurazione") {
// Non è colpa della mail: si rimanda questa e tutte le successive del lotto senza
// consumare tentativi, e il ciclo si ferma.
const prossimo = dopoMinuti(
d.adesso(),
tipo === "limite" ? ATTESA_LIMITE_MINUTI : ATTESA_CONFIGURAZIONE_MINUTI,
);
for (const restante of lotto.slice(indice)) {
await d.segna(restante.id_notifica, "differita", motivo, prossimo);
risultato.differite += 1;
}
risultato.fermato = tipo;
d.log(`ciclo fermato (${tipo}, ${motivo}): ${risultato.differite} mail rimandate`);
return risultato;
}
const ritardo = tipo === "temporaneo" ? ritardoRiprova(notifica.tentativi_fatti) : null;
if (ritardo === null) {
await d.segna(notifica.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`fallita ${notifica.id_notifica} (${notifica.tipo_notifica}, ${tipo}, ${motivo})`);
} else {
await d.segna(notifica.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
risultato.riprovate += 1;
d.log(`da riprovare ${notifica.id_notifica} tra ${ritardo} min (${motivo})`);
}
}
return risultato;
}
// --- Push dei promemoria (M24) ----------------------------------------------------------------
/** Un dispositivo iscritto alla push (riga di `push_subscriptions`). */
export type IscrizionePush = { endpoint: string; p256dh: string; auth: string };
/** Riga restituita da `prendi_push_promemoria()`. */
export type PushDaInviare = {
id_notifica: string;
oggetto: string;
testo: string;
tentativi_fatti: number;
iscrizioni: IscrizionePush[];
};
export type EsitoPush = "inviata" | "riprova" | "fallita" | "saltata";
export type EsitoStatoPush = "ok" | "scaduta" | "temporaneo" | "permanente";
/**
* Cosa vuol dire la risposta del servizio push per quel dispositivo: 2xx accettata; 404 e 410
* iscrizione scaduta (va eliminata); 429 e 5xx errore temporaneo; ogni altro 4xx (400, 401, 403,
* 413) rifiuto permanente, tipicamente chiavi VAPID sbagliate o payload non valido.
*/
export function classificaStatoPush(stato: number): EsitoStatoPush {
if (stato >= 200 && stato < 300) return "ok";
if (stato === 404 || stato === 410) return "scaduta";
if (stato === 429 || stato >= 500) return "temporaneo";
return "permanente";
}
export type DipendenzePush = {
prendi: (max: number) => Promise<PushDaInviare[]>;
segna: (id: string, esito: EsitoPush, errore?: string, prossimo?: Date) => Promise<void>;
invia: (iscrizione: IscrizionePush, titolo: string, testo: string) => Promise<{ stato: number }>;
elimina: (endpoint: string) => Promise<void>;
adesso: () => Date;
log: (messaggio: string) => void;
};
export type RisultatoCicloPush = {
inviate: number;
riprovate: number;
fallite: number;
saltate: number;
iscrizioniRimosse: number;
};
/**
* Un giro delle push dei promemoria: prende un lotto e manda ogni notifica a tutti i dispositivi
* del giocatore. L'esito è uno solo per notifica: `inviata` se almeno un dispositivo ha accettato;
* altrimenti `riprova` (con backoff, poi `fallita`) se qualcuno ha dato un errore temporaneo o è
* fallita la rete; `fallita` se c'è stato solo un rifiuto permanente; `saltata` se le iscrizioni
* erano tutte scadute. I log riportano solo id e numeri: mai gli endpoint, che identificano il
* dispositivo.
*/
export async function elaboraCicloPush(
d: DipendenzePush,
opzioni: { lotto: number },
): Promise<RisultatoCicloPush> {
const risultato: RisultatoCicloPush = {
inviate: 0,
riprovate: 0,
fallite: 0,
saltate: 0,
iscrizioniRimosse: 0,
};
for (const push of await d.prendi(opzioni.lotto)) {
let accettate = 0;
let temporanei = 0;
let permanenti = 0;
const codici: string[] = [];
for (const iscrizione of push.iscrizioni) {
let stato: number | null = null;
try {
stato = (await d.invia(iscrizione, push.oggetto, push.testo)).stato;
} catch {
temporanei += 1;
codici.push("rete");
continue;
}
const tipo = classificaStatoPush(stato);
if (tipo === "ok") accettate += 1;
else {
codici.push(String(stato));
if (tipo === "temporaneo") temporanei += 1;
else if (tipo === "permanente") permanenti += 1;
else {
try {
await d.elimina(iscrizione.endpoint);
risultato.iscrizioniRimosse += 1;
} catch {
d.log(`iscrizione scaduta non eliminata (notifica ${push.id_notifica})`);
}
}
}
}
const motivo = codici.join(",") || undefined;
if (accettate > 0) {
await d.segna(push.id_notifica, "inviata");
risultato.inviate += 1;
d.log(
`push inviata ${push.id_notifica} (${accettate}/${push.iscrizioni.length} dispositivi)`,
);
} else if (temporanei > 0) {
const ritardo = ritardoRiprova(push.tentativi_fatti);
if (ritardo === null) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (tentativi finiti, ${motivo})`);
} else {
await d.segna(push.id_notifica, "riprova", motivo, dopoMinuti(d.adesso(), ritardo));
risultato.riprovate += 1;
d.log(`push da riprovare ${push.id_notifica} tra ${ritardo} min (${motivo})`);
}
} else if (permanenti > 0) {
await d.segna(push.id_notifica, "fallita", motivo);
risultato.fallite += 1;
d.log(`push fallita ${push.id_notifica} (${motivo})`);
} else {
await d.segna(push.id_notifica, "saltata", motivo);
risultato.saltate += 1;
d.log(`push saltata ${push.id_notifica} (nessun dispositivo utile)`);
}
}
return risultato;
}
+141
View File
@@ -0,0 +1,141 @@
// Worker delle notifiche (M22 email, M24 push dei promemoria; DD-036, DD-038): legge le code su
// Supabase e invia via SMTP e via Web Push. Solo chiamate in uscita, nessuna porta da aprire.
// Uso: bun mailer/mailer.mjs. La logica sta in mailer-core.ts; qui c'è solo la colla con rete,
// SMTP e il modulo push dell'app (src/lib/webpush.server.ts, lo stesso usato dalle route).
import nodemailer from "nodemailer";
import { writeFile } from "node:fs/promises";
import { inviaPush } from "../src/lib/webpush.server.ts";
import { elaboraCiclo, elaboraCicloPush, leggiConfig } from "./mailer-core.ts";
const FILE_SALUTE = process.env.MAILER_FILE_SALUTE ?? "/tmp/mailer-ok";
const lettura = leggiConfig(process.env);
if ("errori" in lettura) {
console.error(`Configurazione non valida:\n- ${lettura.errori.join("\n- ")}`);
process.exit(1);
}
const { config } = lettura;
const log = (messaggio) => console.log(`${new Date().toISOString()} ${messaggio}`);
async function rpc(nome, corpo) {
const risposta = await fetch(`${config.supabaseUrl}/rest/v1/rpc/${nome}`, {
method: "POST",
headers: {
apikey: config.serviceRoleKey,
Authorization: `Bearer ${config.serviceRoleKey}`,
"content-type": "application/json",
},
body: JSON.stringify(corpo),
signal: AbortSignal.timeout(15_000),
});
if (!risposta.ok) throw new Error(`rpc ${nome}: HTTP ${risposta.status}`);
const testo = await risposta.text();
return testo ? JSON.parse(testo) : null;
}
/** Elimina un'iscrizione push scaduta (il servizio push ha risposto 404 o 410). */
async function eliminaIscrizione(endpoint) {
const risposta = await fetch(
`${config.supabaseUrl}/rest/v1/push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
{
method: "DELETE",
headers: {
apikey: config.serviceRoleKey,
Authorization: `Bearer ${config.serviceRoleKey}`,
},
signal: AbortSignal.timeout(15_000),
},
);
if (!risposta.ok) throw new Error(`delete push_subscriptions: HTTP ${risposta.status}`);
}
const trasporto = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
...(config.smtp.auth ? { auth: config.smtp.auth } : {}),
});
const dominioMessageId = config.from.split("@")[1] ?? "crapp.local";
const dipendenze = {
prendi: (max) => rpc("prendi_notifiche_email", { p_max: max }),
segna: (id, esito, errore, prossimo) =>
rpc("esito_notifica_email", {
p_id: id,
p_esito: esito,
p_errore: errore ?? null,
p_prossimo: prossimo ? prossimo.toISOString() : null,
}),
invia: (mail) =>
trasporto.sendMail({
from: { name: config.fromName, address: config.from },
to: mail.to,
subject: mail.subject,
text: mail.text,
html: mail.html,
messageId: mail.messageId,
}),
inviateUltime24h: () => rpc("email_inviate_ultime_24h", {}),
adesso: () => new Date(),
log,
};
const dipendenzePush = {
prendi: (max) => rpc("prendi_push_promemoria", { p_max: max }),
segna: (id, esito, errore, prossimo) =>
rpc("esito_push_promemoria", {
p_id: id,
p_esito: esito,
p_errore: errore ?? null,
p_prossimo: prossimo ? prossimo.toISOString() : null,
}),
invia: (iscrizione, titolo, testo) => inviaPush(iscrizione, titolo, testo),
elimina: eliminaIscrizione,
adesso: () => new Date(),
log,
};
const opzioni = {
limiteGiorno: config.limiteGiorno,
lotto: config.lotto,
appUrl: config.appUrl,
dominioMessageId,
};
let fermo = false;
for (const segnale of ["SIGTERM", "SIGINT"]) process.on(segnale, () => (fermo = true));
const attendi = (ms) => new Promise((ok) => setTimeout(ok, ms));
log(`avviato: SMTP ${config.smtp.host}:${config.smtp.port}, ogni ${config.pollSecondi}s`);
log(
config.vapid
? "push dei promemoria: attiva"
: "push dei promemoria: DISATTIVATA (VAPID_PUBLIC_KEY e VAPID_PRIVATE_KEY assenti), solo email",
);
trasporto.verify().then(
() => log("SMTP raggiungibile e credenziali accettate"),
(errore) => log(`SMTP non verificato (${errore?.code ?? "errore"}): controlla SMTP_* nel .env`),
);
while (!fermo) {
try {
const esito = await elaboraCiclo(dipendenze, opzioni);
// Un ciclo fermato dalle credenziali SMTP non segna il container come sano: dopo qualche
// minuto `docker ps` lo mostra "unhealthy" invece di lasciar credere che tutto funzioni.
if (esito.fermato !== "configurazione") await writeFile(FILE_SALUTE, String(Date.now()));
} catch (errore) {
console.error(`${new Date().toISOString()} ciclo fallito: ${errore?.message ?? errore}`);
}
if (config.vapid) {
// Un errore delle push non deve fermare le email né il segnale di salute del worker.
try {
await elaboraCicloPush(dipendenzePush, { lotto: config.lotto });
} catch (errore) {
console.error(`${new Date().toISOString()} ciclo push fallito: ${errore?.message ?? errore}`);
}
}
await attendi(config.pollSecondi * 1000);
}
log("arrestato");
+12
View File
@@ -0,0 +1,12 @@
{
"name": "crapp-mailer",
"private": true,
"type": "module",
"description": "Worker delle notifiche email di CrAPP (M22, DD-036)",
"scripts": {
"start": "bun mailer.mjs"
},
"dependencies": {
"nodemailer": "^10.0.11"
}
}
+249 -2046
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.1.1",
"version": "1.4.2",
"private": true,
"sideEffects": false,
"type": "module",
@@ -20,10 +20,10 @@
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@tanstack/router-plugin": "^1.168.23",
"@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.41",
"@tanstack/react-start": "^1.168.60",
"@tanstack/router-plugin": "^1.168.42",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
+127
View File
@@ -0,0 +1,127 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Reveal } from "@/components/motion/Reveal";
import { cn } from "@/lib/utils";
/**
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
* sua e un titolo, così si distingue anche senza vedere i colori.
*/
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
const toni: Record<TonoAvviso, string> = {
critico: "bg-destructive text-destructive-foreground",
attenzione: "bg-warning text-warning-foreground",
scaduto: "bg-primary text-primary-foreground",
azione: "bg-accent-grad text-accent-foreground",
};
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
export type RigaAvviso = { chi: string; cosa: string };
type ProprietaCorpo = {
tono: TonoAvviso;
icona: LucideIcon;
titolo: string;
/** Frasi di spiegazione, una per paragrafo. */
testi?: string[];
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
elenco?: RigaAvviso[];
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
cliccabile?: boolean;
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
etichetta?: string;
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
riquadri?: boolean;
};
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
export function CorpoAvviso({
tono,
icona: Icona,
titolo,
testi = [],
elenco = [],
cliccabile = false,
etichetta,
riquadri = false,
}: ProprietaCorpo) {
return (
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
<div className="flex items-center gap-3">
<span
aria-hidden
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
>
<Icona className="h-6 w-6" />
</span>
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
{titolo}
</p>
{etichetta ? (
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
{etichetta}
</span>
) : null}
{elenco.length > 1 ? (
<span
aria-label={`${elenco.length} persone`}
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
>
{elenco.length}
</span>
) : null}
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
</div>
{testi.map((t) => (
<p
key={t}
className={cn(
"mt-3 text-[15px] leading-snug",
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
)}
>
{t}
</p>
))}
{elenco.length > 0 ? (
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
{elenco.map((r) => (
<li
key={`${r.chi}-${r.cosa}`}
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
>
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
*/
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
const card = <CorpoAvviso {...corpo} />;
return (
<Reveal indice={indice} className="mx-5 mt-4">
{corpo.cliccabile ? (
<Link
to="/profilo"
search={{ tab: "documenti" }}
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{card}
</Link>
) : (
<div role="status">{card}</div>
)}
</Reveal>
);
}
+72 -85
View File
@@ -1,8 +1,8 @@
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { CalendarClock, FileClock, FileX } from "lucide-react";
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificati,
avvisiCertificatiUtente,
formatDataBreve,
testoScadenza,
type AvvisoCertificato,
@@ -12,44 +12,10 @@ import { oggiISO } from "@/lib/palloni-core";
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
function Card({
colore,
titolo,
righe,
cliccabile,
}: {
colore: "warning" | "primary";
titolo: string;
righe: string[];
cliccabile?: boolean;
}) {
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning"
? "bg-warning text-warning-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`;
const contenuto = (
<>
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> {titolo}
</p>
{righe.map((r) => (
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
{r}
</p>
))}
</>
);
return cliccabile ? (
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
{contenuto}
</Link>
) : (
<div className={classi}>{contenuto}</div>
);
}
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
/**
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
*/
export function AvvisoCertificati() {
const admin = useIsAdmin();
const ruoliPronti = useRuoliPronti();
@@ -61,53 +27,74 @@ export function AvvisoCertificati() {
const oggi = oggiISO();
if (admin) {
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
if (scaduti.length === 0 && inScadenza.length === 0) return null;
return (
<>
{scaduti.length > 0 ? (
<Card
colore="primary"
titolo="Certificati scaduti"
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Card
colore="warning"
titolo="Certificati in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
if (!base) return null;
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
const mio = scaduti[0] ?? inScadenza[0];
if (!mio) return null;
return mio.giorni < 0 ? (
<Card
colore="primary"
titolo="Certificato scaduto"
// Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
const { personale, personaleMancante, staff } = avvisiCertificatiUtente(rosa, profili, oggi, {
admin,
base,
});
const cardPersonale = personaleMancante ? (
<Avviso
tono="critico"
icona={FileX}
titolo="Certificato mancante"
cliccabile
righe={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Card
colore="warning"
titolo="Certificato in scadenza"
cliccabile
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
/>
) : personale ? (
personale.giorni < 0 ? (
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificato scaduto"
cliccabile
testi={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificato in scadenza"
cliccabile
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
/>
)
) : null;
if (!staff) return cardPersonale;
const { scaduti, inScadenza, mancanti } = staff;
return (
<>
{cardPersonale}
{mancanti.length > 0 ? (
<Avviso
tono="critico"
icona={FileX}
titolo="Certificati mancanti"
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
/>
) : null}
{scaduti.length > 0 ? (
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificati scaduti"
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificati in scadenza"
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
}
+13 -11
View File
@@ -33,7 +33,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
<DrawerDescription className="mt-1 text-sm leading-relaxed">
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{def.descrizione}
</DrawerDescription>
</div>
@@ -42,13 +42,15 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
{stato ? (
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Stato attuale
</p>
{grado ? (
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
{meta?.label}
</span>
) : (
<span className="text-xs font-bold uppercase text-muted-foreground">
<span className="text-[13px] font-bold uppercase text-muted-foreground">
In progresso
</span>
)}
@@ -59,19 +61,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
{stato.prossimaSoglia ? (
<>
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
</p>
</>
) : (
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
<p className="mt-2 text-[13px] text-success">Hai raggiunto il livello massimo.</p>
)}
</div>
) : null}
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Soglie
</p>
<div className="grid grid-cols-3 gap-2">
@@ -88,7 +90,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
<p
className={cn(
"text-xs font-bold uppercase",
"text-[13px] font-bold uppercase",
raggiunto ? gm.text : "text-muted-foreground",
)}
>
@@ -102,7 +104,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
{def.soglie[g]}
</p>
<p className="text-xs text-muted-foreground">{def.unita}</p>
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
</div>
);
})}
@@ -110,7 +112,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
</div>
{def.celebrazione ? (
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
“{def.celebrazione}”
</p>
) : null}
@@ -142,7 +144,7 @@ export function BadgeDrawer({
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
+3 -3
View File
@@ -51,11 +51,11 @@ export function CelebrazioneBadge() {
{notifica.emoji}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-accent">
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
</p>
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
<p className="mt-1.5 text-sm text-muted-foreground">{notifica.testo}</p>
<p className="mt-1.5 text-[15px] text-muted-foreground">{notifica.testo}</p>
</div>
<button
type="button"
@@ -69,7 +69,7 @@ export function CelebrazioneBadge() {
<button
type="button"
onClick={chiudi}
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-[15px] font-bold uppercase tracking-wide text-accent-foreground"
>
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
</button>
+28 -24
View File
@@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
<div className="flex items-center justify-between">
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
{meta ? (
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
<span className={cn("text-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
) : null}
</div>
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
{b.valore} {b.def.unita}
</p>
{b.prossimaSoglia ? (
<>
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
<p className="mt-1 text-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
</>
) : (
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
)}
</Reveal>
);
@@ -90,13 +90,15 @@ function SocialDrawer({
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
<DrawerDescription className="mt-1 text-sm leading-relaxed">
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{cat.descrizione}
</DrawerDescription>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Vinto
</p>
<p className="mt-1 font-display text-3xl leading-none">
{conteggio}{" "}
<span className="text-lg text-muted-foreground">
@@ -109,7 +111,7 @@ function SocialDrawer({
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
@@ -139,7 +141,7 @@ export function CollezioneBadge({
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
Collezione badge
</p>
<p className="font-display text-4xl leading-none">
@@ -147,7 +149,7 @@ export function CollezioneBadge({
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
</p>
</div>
<p className="text-xs text-primary-foreground/70">
<p className="text-[13px] text-primary-foreground/70">
<Numero valore={pct} suffisso="%" /> completata
</p>
</div>
@@ -156,12 +158,12 @@ export function CollezioneBadge({
{vicino ? (
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
<p className="text-xs font-bold uppercase tracking-wide text-accent">
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
Prossimo traguardo
</p>
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
</p>
</div>
@@ -169,7 +171,7 @@ export function CollezioneBadge({
{c.sbloccati.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Sbloccati ({c.sbloccati.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -184,7 +186,7 @@ export function CollezioneBadge({
{c.inProgresso.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
In progresso ({c.inProgresso.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -199,7 +201,7 @@ export function CollezioneBadge({
{socialVinti.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Votati dai compagni
</p>
<div className="grid grid-cols-2 gap-2">
@@ -207,8 +209,8 @@ export function CollezioneBadge({
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-accent/25">
<p className="text-lg leading-none">{cat.emoji}</p>
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1 text-[15px] font-bold leading-tight">{cat.nome}</p>
<p className="text-[13px] text-muted-foreground">
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
</p>
</div>
@@ -219,7 +221,7 @@ export function CollezioneBadge({
) : null}
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Badge segreti
</p>
<div className="grid grid-cols-2 gap-2">
@@ -229,8 +231,8 @@ export function CollezioneBadge({
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-oro/40">
<Icon className="h-6 w-6 text-oro" />
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
{b.def.celebrazione ?? "Badge segreto sbloccato."}
</p>
</div>
@@ -243,8 +245,10 @@ export function CollezioneBadge({
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
>
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
<p className="mt-1.5 text-[15px] font-bold leading-tight text-muted-foreground">
???
</p>
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
</div>
))}
</div>
+19 -17
View File
@@ -49,7 +49,7 @@ export function MetaPartitaCsi({
.join(" · ");
if (!voci && !link) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
{voci ? <span>{voci}</span> : null}
{link ? (
<a
@@ -68,17 +68,17 @@ export function MetaPartitaCsi({
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
return (
<div>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{squadra.squadra}
</p>
<div className="mt-2 space-y-1">
{squadra.titolari.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
<div key={i} className="flex items-center gap-2 text-[15px]">
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
{g.numero}
</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
</div>
))}
</div>
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
</p>
<div className="mt-1 space-y-1">
{squadra.panchina.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<div key={i} className="flex items-center gap-2 text-[13px] text-muted-foreground">
<span className="w-6 shrink-0 text-center">{g.numero}</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
</div>
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
</>
) : null}
{squadra.staff.length > 0 ? (
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
<div className="mt-3 space-y-0.5 text-[13px] text-muted-foreground">
{squadra.staff.map((s, i) => (
<div key={i}>
{s.nome} · {s.ruolo}
@@ -121,7 +121,7 @@ function BarraPrecedenti({
}) {
const totale = noi + avversario || 1;
return (
<div className="text-xs">
<div className="text-[13px]">
<div className="flex items-center justify-between text-muted-foreground">
<span className="font-bold text-foreground">{noi}</span>
<span>{label}</span>
@@ -139,7 +139,7 @@ const NOME_NOI = "CRAP Volley";
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
function TestataSquadre({ avversario }: { avversario: string }) {
return (
<div className="flex items-center justify-between text-xs font-bold">
<div className="flex items-center justify-between text-[13px] font-bold">
<span className="truncate text-accent">{NOME_NOI}</span>
<span className="truncate text-right text-muted-foreground">{avversario}</span>
</div>
@@ -158,7 +158,7 @@ function BarraProbabilita({
const favoritaNoi = noi >= avversarioPct;
return (
<div>
<p className="mb-2 text-xs text-muted-foreground">
<p className="mb-2 text-[13px] text-muted-foreground">
Probabilità di vittoria (calcolo CSI):{" "}
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
@@ -171,13 +171,13 @@ function BarraProbabilita({
</div>
<div className="flex h-7 overflow-hidden rounded-full">
<div
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
className="flex items-center justify-center bg-success text-[13px] font-bold text-success-foreground"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
style={{ width: `${avversarioPct}%` }}
>
{avversarioPct.toFixed(0)}%
@@ -197,7 +197,7 @@ function BloccoPrecedenti({
return (
<div className="space-y-3">
<TestataSquadre avversario={avversario} />
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{precedenti.totale > 0
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
@@ -248,20 +248,22 @@ export function DettaglioCsiEsteso({
const { data, isLoading } = useCsiPartita(matchId);
if (isLoading) {
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
return (
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
);
}
if (!data || (!data.formazioni && !data.precedenti)) return null;
return (
<div className="space-y-4">
{data.giornata || data.nota ? (
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
</p>
) : null}
{data.formazioni ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -272,7 +274,7 @@ export function DettaglioCsiEsteso({
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
</div>
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
+13 -12
View File
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store";
@@ -75,7 +75,8 @@ export function EventoCard({
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter(inRosa);
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -101,10 +102,10 @@ export function EventoCard({
<Cake className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
<h3 className="truncate text-[15px] font-bold leading-tight">{evento.titolo}</h3>
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
</div>
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
</div>
</Card>
@@ -139,13 +140,13 @@ export function EventoCard({
<div className="flex items-start justify-between gap-3">
<span
className={cn(
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
"inline-block rounded-full px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-wide",
tipo.className,
)}
>
{tipo.label}
</span>
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
</span>
@@ -153,10 +154,10 @@ export function EventoCard({
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
{sottotitoloPartita ? (
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">{sottotitoloPartita}</p>
) : null}
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{evento.ora}</span>
@@ -174,12 +175,12 @@ export function EventoCard({
</div>
{noteCard ? (
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
) : null}
{metaStato ? (
<p
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
aria-live="polite"
>
<span aria-hidden>{metaStato.emoji}</span>
@@ -187,7 +188,7 @@ export function EventoCard({
</p>
) : null}
{io ? (
{io && eConvocato(evento, io.id) ? (
<div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group"
+142
View File
@@ -0,0 +1,142 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
import { Barra } from "@/components/motion/Barra";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
return (
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo}
</p>
<ul className="space-y-1.5">
{voci.map((v) => (
<li key={v} className="flex gap-2 text-[15px] leading-snug">
<span aria-hidden className="shrink-0 font-bold text-accent">
{segno}
</span>
<span>{v}</span>
</li>
))}
</ul>
</div>
);
}
function Blocco({ titolo, testo }: { titolo: string; testo: string }) {
return (
<div>
<p className="mb-1 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo}
</p>
<p className="text-[15px] leading-relaxed">{testo}</p>
</div>
);
}
/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
const d = o.dettaglio;
return (
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 pt-0">
<div className="flex items-start gap-4">
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-secondary text-2xl ring-1 ring-border">
{o.emoji}
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{o.titolo}</DrawerTitle>
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{o.descrizione}
</DrawerDescription>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<div className="flex items-center justify-between">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Stato attuale
</p>
<span
className={cn(
"text-[13px] font-bold uppercase",
fatto ? "text-success" : "text-muted-foreground",
)}
>
{fatto ? "Completato" : "In corso"}
</span>
</div>
<p className="mt-1 font-display text-3xl leading-none">
{o.valore} <span className="text-lg text-muted-foreground">su {o.target}</span>{" "}
<span className="text-lg text-muted-foreground">{o.unita}</span>
</p>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-[13px] text-muted-foreground">
{pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-[13px] font-semibold text-accent">{microcopyObiettivo(o)}</p>
</div>
<Blocco titolo="Come si calcola" testo={d.comeSiCalcola} />
<Elenco titolo="Cosa conta" voci={d.conta} segno="+" />
{d.perse ? <Elenco titolo="Presenza persa" voci={d.perse} segno="−" /> : null}
<Elenco
titolo={d.perse ? "Non entra nel calcolo" : "Cosa non conta"}
voci={d.nonConta}
segno={d.perse ? "·" : "−"}
/>
<Blocco titolo="Quando vale" testo={d.periodo} />
<Blocco titolo="Da dove arrivano i dati" testo={d.fonte} />
<Blocco titolo="Un esempio" testo={d.esempio} />
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
{o.impatto}
</p>
</div>
);
}
/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
export function ObiettivoDrawer({
obiettivo,
children,
}: {
obiettivo: ObiettivoSquadra;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="sr-only">
<DrawerTitle>{obiettivo.titolo}</DrawerTitle>
<DrawerDescription>{obiettivo.descrizione}</DrawerDescription>
</DrawerHeader>
<SchedaObiettivo o={obiettivo} />
<DrawerFooter>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more