146 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 c12011d330 Porta la versione a 1.2.1: turno palloni e solleciti presenze automatici
Changelog della 1.2.1, versione in package.json e stato del progetto: la
migration m26 è applicata in produzione e tutte le 37 migration sono allineate.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration m17_notifiche_utente non ancora applicata in produzione.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Aggiunge test/integration/bonifica-evento.test.ts: inserisce una riga
orfana e una storica su id Scout/CSI, richiama la funzione via RPC e
verifica che tocchi solo la prima. Documenta l'aggiornamento in
DESIGN_DECISIONS.md (DD-029), DATABASE.md, test/README.md e CHANGELOG.md.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Autovoto (la doc aveva ragione)

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

Test che guardavano la colonna sbagliata

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

Tre riletture in meno

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

Documentazione riallineata

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

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

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

Due modifiche al codice servivano per poter scrivere i test:

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:03:18 +02:00
Ivan CacciariandCursor 3d8cb22235 Compatta la classifica interna Squadra e allinea le tab a sottolineatura.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 18:50:11 +02:00
davideandClaude Opus 5 fb6bde279e Toglie la notifica di prova dalle Opzioni.
Era impalcatura per diagnosticare "le push non arrivano": ha fatto il suo
lavoro, la causa e' documentata nei limiti noti. Lasciare il pulsante avrebbe
tenuto in piedi anche la route push-prova e notificaDiProva, senza piu' nessuno
che le usa.

Via il pulsante, la funzione client, la route e i test che la coprivano. Resta
in git se dovesse servire di nuovo.

inviaPush continua a tornare { stato, corpo } e a loggare i rifiuti: serve alle
tre route che mandano notifiche, non solo alla prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:16:41 +02:00
davideandClaude Opus 5 5c709c1a12 Annota la causa vera del "non arriva" su Android: Chrome non svegliabile.
Su Android non riceve la webapp installata: riceve Chrome, tramite Google Play
Services. Il WebAPK e' solo l'identita' con cui la notifica viene mostrata. Se
il sistema non puo' avviare Chrome, il messaggio resta in coda e compare tutto
insieme al lancio successivo — "arriva solo quando riapro l'app".

Verificato sul campo su Motorola: con Chrome vivo in secondo piano la push
arriva ad app chiusa e schermo bloccato, WebAPK compreso. Quindi server,
cifratura, service worker, permesso notifiche e canale erano gia' corretti e
l'unica condizione che fallisce e' Chrome non in esecuzione. Mettere "Senza
restrizioni" solo su CrAPP non basta e depista.

Documentato anche come misurarlo con chrome://gcm-internals, incluso il
tranello: tenere quella scheda aperta tiene Chrome vivo e falsa la prova.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:57:05 +02:00
davideandClaude Opus 5 78d41daf4f Ritarda di 10 secondi la notifica di prova, per poter chiudere l'app.
Il pulsante si preme con l'app aperta, quindi la push arrivava sempre in primo
piano: l'unico caso che non serve provare. Ora la route aspetta `ritardoMs`
(10s di default, 25 al massimo) prima di inviare, e il toast dice di chiudere
l'app subito.

L'attesa tiene aperta la funzione, quindi il tetto e' la durata massima
concessa dall'hosting: annotato come ponytail nel codice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:44:29 +02:00
davideandClaude Opus 5 8047e58ae0 Mostra l'ora italiana nella notifica di prova.
Il server gira in UTC: `toLocaleTimeString("it-IT")` senza `timeZone` formattava
nel fuso del server, quindi la prova diceva 12:40 con un telefono che segnava le
14:40 e sembrava un orologio sfasato. E' solo il messaggio di prova: i testi
delle notifiche vere prendono data e ora dai campi dell'evento, non
dall'orologio del server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:42:05 +02:00
davideandClaude Opus 5 d8f334007b Fa arrivare le push ad app chiusa e rende diagnosticabile quando non arrivano.
Il testo della notifica viaggia ora cifrato dentro la push (aes128gcm, RFC
8188/8291) invece di essere recuperato dal service worker con una fetch al
risveglio. Era quella fetch a non chiudersi in tempo: ad app chiusa il browser
tiene vivo il worker pochi secondi, showNotification non veniva mai chiamata e
non compariva niente, mentre ad app aperta con la rete calda sembrava tutto a
posto. La POST porta anche Urgency: high, che chiede la consegna immediata
invece di far accumulare i messaggi fino al risveglio del dispositivo.

Cadono i pezzi che esistevano solo per rimediare al payload vuoto: la route
push-messaggio, la coda promemoria_push con la sua scadenza a 12 ore,
messaggioPalloniOggi() e il timeout nel worker. Tutti e tre i mittenti avevano
gia il testo pronto prima di inviare.

Il worker si aggiorna da solo all'avvio e a ogni ritorno in primo piano
(mantieniWorkerPushAggiornato): nella webapp installata quello vecchio puo
sopravvivere a lungo, e senza questo un dispositivo resterebbe fermo alla
versione che va a cercare il testo in rete.

Profilo -> Opzioni ha "Mandami una notifica di prova", visibile solo a notifiche
attive: manda una push a questo dispositivo e riporta stato HTTP, corpo della
risposta e se l'endpoint risulta davvero in push_subscriptions. Senza, "non
arriva" era cieco: ogni prova richiedeva un admin, un evento nello stato giusto
e una seconda persona, e la risposta del servizio push veniva buttata via.
inviaPush torna { stato, corpo } e logga il corpo sui rifiuti.

Dalle prove sul campo: a parita di server, iPhone installato da Home riceve ad
app chiusa. Su Android installato come webapp resta da verificare: il WebAPK e
un'app Android a se, con permesso notifiche (Android 13+) e voce batteria
distinti da quelli di Chrome. Annotato nei limiti noti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:24:07 +02:00
davideandClaude Opus 5 2b8ba340a6 Fa scadere i promemoria in coda e smette di dare la colpa alle iscrizioni.
Provando il promemoria palloni in produzione la notifica risultava inviata ma
non arrivava. La causa non era il codice: l'iscrizione di destinazione era
scaduta, FCM l'ha accettata con 2xx e ha buttato via il messaggio, e un minuto
dopo il dispositivo si è re-iscritto con un endpoint nuovo. Un 2xx dal server
push non significa consegnato, e non manda il 404/410 che farebbe pulire
push_subscriptions: è annotato fra i limiti noti, perché dal server non è
distinguibile.

Il difetto vero l'ha fatto emergere quella caccia. promemoria_push si svuota
solo quando il dispositivo legge il messaggio, quindi se la push non arriva mai
la riga resta per sempre — e push-messaggio serve la coda con priorità sul testo
calcolato. In produzione ce n'erano dieci, la più vecchia del 2 settembre: alla
notifica successiva, di qualunque tipo, quel telefono avrebbe mostrato un
sollecito presenze per un evento già passato.

Ora un promemoria vale 12 ore. La riga si cancella comunque alla prima lettura,
scaduta o no: cancellare solo le fresche lascerebbe le vecchie in coda a
dirottare ogni notifica futura, cioè il bug. Così la coda si smaltisce da sola e
le righe orfane già in produzione non vanno ripulite a mano.

Il messaggio del pulsante distingue infine i due casi che prima confondeva:
nessuna iscrizione fra gli incaricati, oppure iscrizioni presenti e invio non
riuscito. Il primo è informativo, il secondo è un errore — dirlo sbagliato
manda a cercare il problema dalla parte opposta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:25:21 +02:00
davideandClaude Opus 5 ee9f3f1b8c Il promemoria palloni lo manda un admin dalla pagina evento (DD-025).
La route era disegnata per un cron quotidiano: calcolava chi è di turno oggi e
gli mandava una push. Ma nel repository nessun cron esiste, e palloni.md lo
annotava già come "da verificare lato hosting": nei fatti quel promemoria non è
mai partito. Il segreto condiviso introdotto ieri proteggeva una porta che
nessuno apriva, al prezzo di una variabile d'ambiente da configurare ovunque.

Ora la fa partire un amministratore dal pulsante "Avvisa chi è di turno", dentro
il riquadro palloni dell'evento. La route accetta un eventoId e avvisa i
destinatari di quell'evento invece della giornata corrente: chi deve prendere i
palloni e chi deve riportarli, con un testo diverso per ciascuno. Stesso
precedente di apri-sondaggio, manuale fin dalla v1.0.6.

Il testo va in coda su promemoria_push prima dell'invio. Serve: la push parte
vuota e il service worker chiede a push-messaggio cosa mostrare, ma quella route
sa raccontare solo la giornata corrente, quindi un avviso mandato il martedì per
il sabato arriverebbe con il testo generico.

avvisiPalloniEvento() è la nuova funzione pura, con i suoi test: due destinatari
con testi diversi, un avviso solo quando sono la stessa persona, niente avvisi
per un evento inesistente o senza turni. Un'asserzione verifica che il testo non
dica mai "oggi", perché può arrivare giorni prima.

richiediSegreto e CRON_SEGRETO spariscono: tutte e tre le route di notifica
usano ora richiediAdmin, e non resta nessuna variabile da configurare.
destinatariPromemoriaPalloni() resta in palloni-core.ts con i suoi test perché
push-messaggio continua a usarla per il testo calcolato al volo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:15:39 +02:00
davideandClaude Opus 5 847972b582 Chiede le credenziali alle route che avvisano tutta la squadra (DD-024).
Le route in src/routes/api/public/ girano con la service role e saltano la RLS,
quindi DD-023 non le copre. Nessuna 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 far suonare i
telefoni della squadra, e 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" più il timestamp in base 36, compare negli URL che la squadra
si scambia ed è elencabile da qualsiasi utente loggato.

auth-route.server.ts porta i due controlli, diversi perché i chiamanti sono
diversi. apri-sondaggio e sollecita-presenze usano richiediAdmin: token della
sessione verificato con auth.getUser, poi ruolo admin da user_roles, la stessa
fonte di ruoli.ts. Il controllo precede la validazione dell'input, così la
risposta non rivela nemmeno se un evento esiste. promemoria-palloni usa
richiediSegreto, perché la chiama un cron che una sessione non ce l'ha: se
CRON_SEGRETO non è configurata la route resta chiusa con 503, perché una porta
che si riapre da sola quando manca una variabile non se ne accorge nessuno.

csi, push-config, push-subscribe e push-messaggio restano aperte: le chiamano il
browser prima del login e il service worker, dove qualsiasi segreto finirebbe
nel bundle.

Lato client i due pulsanti admin mandano il token con intestazioniAutenticate(),
letto al momento della chiamata e non da uno stato React.

permessi-route.test.ts copre il giro intero — nessun token, giocatore, admin —
avviando il server di sviluppo puntato al database locale, perché servono utenti
veri. Il controllo positivo è il 404: l'admin supera l'accesso e arriva alla
validazione. In api.test.ts restano i rifiuti che non richiedono un utente e
sparisce la verifica della validazione di sollecita-presenze, che ora sta dietro
all'accesso.

I limiti noti di palloni.md sono aggiornati: il secret che il piano originale
prevedeva ora c'è. Resta vero che nessun cron chiama la route, quindi il
promemoria quotidiano non parte da solo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:05:39 +02:00
davideandClaude Opus 5 93b61d422a Limita le scritture del database a chi le fa (M11, DD-023).
Le tabelle della v1.0 sono nate con policy USING (true) per anon e
authenticated. M4 ha tolto il GRANT ad anon e la cosa è passata per "ora è
chiuso", ma per gli autenticati non era rimasto nessun limite. Verificato sul
database locale con un utente appena creato, senza ruolo e senza slot nella
rosa: POST su eventi_app risponde 201, DELETE risponde 200. Qualsiasi giocatore
loggato poteva svuotare il calendario o riscrivere il voto di un altro parlando
direttamente con PostgREST, saltando l'interfaccia che quei pulsanti glieli
nasconde. Il permesso viveva solo nei componenti, cioè nel posto che un
attaccante non usa.

M11 fa dire alle policy quello che l'interfaccia già fa: eventi_app agli admin,
risposte_presenze e cacche_partita alla propria riga, i tre voti al proprio
votante_id. L'admin resta incluso ovunque, perché DD-017 gli riconosce già il
diritto di agire al posto del giocatore.

turni_palloni e le tabelle scout restano aperte di proposito: nell'interfaccia
non hanno nessun gate, quindi stringerle sarebbe una funzionalità nuova e non
una messa in sicurezza. Un test lo fissa, così se il gate arriva qualcuno se ne
accorge.

L'identità è lo slot di giocatori_squadra collegato all'account, con lo stesso
EXISTS delle policy dei profili: mio_giocatore_id() di M2 era già stata rimossa
dalla migration di correzione e non va reintrodotta.

I cinque test nuovi in permessi.test.ts hanno ognuno il proprio controllo
positivo — l'admin crea l'evento, il giocatore salva la propria presenza —
perché un database che rifiuta tutto passerebbe qualsiasi test di sola
negazione. Provata con db reset da zero; non applicata in produzione.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:51:27 +02:00
davideandClaude Opus 5 fe0c938358 Verifica che gli upsert dell'app scrivano sulla riga giusta.
Ogni salvataggio di CrAPP è un upsert con un onConflict scritto a mano nei hook
di src/lib/. Se quella chiave non corrisponde al vincolo UNIQUE della tabella
non arriva nessun errore: il database sovrascrive la riga sbagliata, e il difetto
si vede settimane dopo in una media che non torna. Nessuna delle sedici
scritture era mai stata eseguita da un test.

test/integration/scritture.test.ts ripete le stesse chiamate dei hook contro il
database locale e conta cosa resta nella tabella. Le due regole opposte che
nessuno verificava: le pagelle tengono un voto per ogni votato — se il conflitto
fosse su (match, votante) ogni voto cancellerebbe il precedente — mentre l'MVP
ne tiene uno solo per votante e partita. Più badge social per categoria, cacche,
turni palloni, risposte presenze (con l'istante che alimenta la serie di
conferme) e lo stato jsonb dello scout, che viene sostituito e non fuso. In più
i due CHECK su cui l'app conta: niente autovoto, voto fra 1 e 10.

Le righe usano il prefisso test-scritture e spariscono in un finally. La lettura
delle credenziali locali passa da test/helpers/locale.ts, ora che la usano due
file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:41:26 +02:00
davideandClaude Opus 5 0addafa76f Separa il calcolo delle presenze del mese dagli hook e lo copre con i test.
La percentuale di presenze degli ultimi 30 giorni decide le convocazioni, ma
stava dentro due useMemo — scritta due volte, una per giocatore e una per la
rosa — e nessun test la toccava. Le tre regole che la determinano non sono
ovvie: la finestra è di 30 giorni, il ritardo conta come presenza, e convocati
vuoto significa tutta la rosa attiva.

presenze-mese-core.ts contiene ora il calcolo puro, presenze-mese.ts solo il
collegamento agli hook. Comportamento invariato, inclusa la differenza fra i due
percorsi: per giocatore si filtrano i convocati, per la rosa l'elenco vuoto si
espande agli attivi.

Il test fissa le tre regole e i casi che le rompono: nessun evento nella
finestra dà 0 e non NaN, chi non è convocato resta fuori dal denominatore, chi
non ha mai risposto ci resta dentro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:41:14 +02:00
davideandClaude Opus 5 94673090f1 Verifica i permessi per ruolo contro il database Supabase locale.
Le policy RLS scritte su auth.uid() e il trigger di DD-016 non erano coperti da
nessun test: schema-profili prova solo l'utente anonimo, e sul database di .env
non si può scrivere perché è quello di produzione.

Il nuovo test/integration/permessi.test.ts crea utenti veri sullo stack locale
(npx supabase start) e interroga il database come loro: un giocatore vede e
modifica solo il proprio profilo, non ne cancella, non cambia numero e ruolo
mentre reclama uno slot, non prende lo slot di un altro, non si assegna il ruolo
admin e non vede i ruoli altrui. Un controllo positivo sull'admin evita il falso
verde di un database completamente chiuso.

Prende URL e chiavi da `supabase status` invece che da .env e si ferma se l'URL
non è locale: un .env puntato alla produzione non deve poter trasformare un test
in una scrittura sul database vero. Senza stack locale si salta con il motivo,
quindi la suite resta verde su una macchina senza Docker. Ogni test ripristina
in un finally lo stato che tocca, così si rilancia senza db reset.

test/README.md documenta il flusso Docker e corregge la convenzione: non è più
«nessun test scrive sul database» ma «sul database di .env non scrive nessuno».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:22:45 +02:00
davideandClaude Sonnet 5 50a712c276 Allinea l'altezza di input, select e date nei form a un'unica misura.
input[type="date"] resta più alto di un input di testo anche con
appearance-none: h-10 esplicito su classiInput allinea tutti i campi.
La textarea note usa h-auto per non perdere le due righe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 11:07:15 +02:00
davideandClaude Sonnet 5 77f587b53b Uniforma l'altezza dei <select> agli altri campi dei form su Android.
Su Android il controllo nativo di <select> ignora parte del padding di
classiInput e risultava più alto o più basso degli input accanto.
appearance-none lo riporta a una scatola CSS normale; la freccia va
ridisegnata a mano perché appearance-none la fa sparire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:57:05 +02:00
davideandClaude Sonnet 5 ee21278e82 Rimuove mem/, superata dalla documentazione in docs/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:43:16 +02:00
davideandClaude Sonnet 5 d5035cbf55 Mostra la versione dell'app nella tab Opzioni del profilo.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:40:34 +02:00
davideandClaude Sonnet 5 a8bde94153 Il link "Storico" in Ultima partita apre lo storico partite in classifica, non la rosa.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:34:03 +02:00
davideandClaude Sonnet 5 41ed3a1f0f Conta le presenze solo sugli eventi già passati, non su quelli odierni o futuri.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:29:34 +02:00
davideandClaude Sonnet 5 208dd5f2f6 Blocca la modifica delle presenze e il sollecito per eventi già passati.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:27:12 +02:00
davideandClaude Sonnet 5 d4a02fb606 Rimuove il link duplicato per tornare al calendario, basta la navbar in basso.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 10:21:14 +02:00
217 changed files with 24839 additions and 3110 deletions

No files matched your search

+11 -1
View File
@@ -40,4 +40,14 @@ dist-ssr
# Optional
.vercel
# Supabase CLI local state
supabase/.temp/
supabase/.temp/
# Backup locali del database (contengono dati reali degli utenti):
# ignora tutto il contenuto generato, ma tieni in git lo script e il README.
backups/*
!backups/backup.sh
!backups/download-storage.mjs
!backups/storage-core.ts
!backups/README.md
# Automazioni operative locali (backup cifrati, playbook con dati sensibili)
ops/
@@ -1,38 +0,0 @@
# Obiettivi di squadra: sezione in Squadra + widget in home
## Stato attuale
Al momento c'è un solo "obiettivo di squadra" ed è hardcoded nella home (`src/routes/index.tsx`, riga 136): **"90% di presenze ad agosto"** con una barra finta all'82%. Non esiste uno schema dati né una sezione dedicata, e non è collegato a calendario, presenze o statistiche. Le voci "obiettivi" nelle descrizioni di Squadra e Profilo si riferiscono ai badge individuali, non a obiettivi di squadra.
## Cosa faccio
1. **Modello dati locale** in `src/lib/crapp-data.ts`
- Nuovo tipo `ObiettivoSquadra`: id, titolo, descrizione, target, valore attuale, unità, scadenza (opzionale), icona.
- Array `obiettiviSquadra` con gli obiettivi demo della stagione.
2. **Sezione "Obiettivi di squadra" dentro la scheda Squadra** (`src/routes/squadra.tsx`)
- Nuova sezione con la lista completa degli obiettivi: barra di progresso, percentuale, stato (in corso / completato).
- Ordinati con gli obiettivi in corso in cima e i completati in fondo.
- Nessuna nuova rotta e nessuna modifica al bottom nav.
3. **Widget home dinamico** (`src/routes/index.tsx`)
- Sostituisce l'obiettivo hardcoded: mostra sempre il primo obiettivo in corso preso dalla lista.
- Progresso calcolato dai dati reali dove possibile (es. presenze derivate dagli eventi).
4. **Obiettivi demo iniziali**
- 90% di presenze ad agosto (collegato agli eventi di agosto).
- 70% di risposte entro 24h nel prossimo mese.
- Prima vittoria del campionato (collegato allo storico match).
- 5 vittorie in campionato (collegato allo storico match).
- 10 vittorie in campionato (collegato allo storico match).
- 1 evento di squadra al mese (pizzata, ecc.).
## Cosa non cambia
- Resta un prototipo offline: i dati restano in `src/lib/crapp-data.ts`.
- I badge individuali restano come sono in `src/lib/badges.ts` e nella rosa di `src/routes/squadra.tsx`.
- Bottom nav e rotte invariate.
@@ -1,58 +0,0 @@
# Rendere CrAPP operativa: migrazione dal prototipo al Cloud
## Stato attuale
L'app è un prototipo UI con alcune funzioni già collegate a Lovable Cloud:
- Già sul Cloud: voti MVP (`mvp_voti`), iscrizioni push (`push_subscriptions`), sessioni scout (`scout_sessioni`), turni palloni (`turni_palloni`).
- Ancora in locale come dati demo: rosa giocatori, calendario eventi, storico partite, classifica CSI, statistiche individuali, presenze/assenze, badge.
## Cosa serve per renderla operativa
1. **Rosa e profili giocatori sul database**
- Creare tabella `profiles` (o `giocatori`) con nome, numero maglia, ruolo, data di nascita, foto profilo.
- Collegare ogni riga all'utente autenticato corrispondente.
- Rimuovere la rosa statica da `src/lib/crapp-data.ts` e caricarla dal backend.
2. **Autenticazione reale**
- Sostituire la semplice selezione "Chi sei?" in `localStorage` con login email/password o OAuth (Google).
- Ogni giocatore accede con le proprie credenziali e vede solo i propri dati modificabili.
- Necessaria per garantire che uno scout o un voto MVP provenga davvero da quel giocatore.
3. **Calendario eventi persistente**
- Tabella `eventi` con tipo, titolo, data, ora, luogo, avversario, casa/fuori.
- Tabella `presenze` (evento_id, giocatore_id, stato, aggiornato_il).
- I compleanni possono restare derivati dalla data di nascita dei giocatori.
4. **Statistiche e badge dinamici**
- Tabella `statistiche` o `azioni_scout` (evento_id, giocatore_id, tipo azione, valore, creato_il).
- I badge vengono calcolati in tempo reale dalle statistiche accumulate, senza valori fissi in `crapp-data.ts`.
5. **Scout live collegato ai dati reali**
- Le azioni registrate in `scout.tsx` devono scrivere sulle tabelle eventi/statistiche.
- Mantenere il lock di modifica singolo e l'attivazione solo il giorno della partita.
6. **Classifica CSI**
- Tabella `classifica` aggiornata manualmente da un admin o importata dal sito CSI quando disponibile.
- Per ora nessuna API CSI ufficiale: si inserisce a mano o si copia/incolla.
7. **Notifiche push definitive**
- Verificare che i cron job inviino correttamente i promemoria palloni.
- Aggiungere notifiche per conferma eventi, promemoria presenze, MVP votabile.
8. **Ruoli e permessi**
- Definire chi può creare/modificare eventi (capitano/admin).
- Chi può fare scout (designato per partita).
- Chi può modificare i turni palloni (tutti, come da tua richiesta).
## Cosa resta salvato nel Cloud
Sì: tutto ciò che viene scritto sulle tabelle Lovable Cloud/Supabase resta salvato online e condiviso tra tutti i dispositivi.
I dati demo in `src/lib/crapp-data.ts` invece no: sono file statici, quindi ogni aggiornamento dell'app li sovrascrive e ogni telefono li vede identici.
## Decisioni da prendere insieme
- Vuoi abilitare login email/password per ogni giocatore, o preferisci mantenere la selezione "Chi sei?" senza password per semplicità?
- Chi gestirà inserimento eventi e aggiornamento classifica: solo alcuni o tutta la squadra?
- Vuoi procedere per fasi (prima rosa + calendario + presenze, poi statistiche) o tutto insieme?
@@ -1,25 +0,0 @@
# Ridimensionamento badge nella lista squadra
## Obiettivo
Rendere i badge accanto al nome del giocatore nella lista squadra più compatti e meno invasivi, mantenendo lo stile stilizzato (icone Lucide colorate per grado) e lasciando la scheda espansa con una dimensione leggibile.
## Modifiche previste
1. **Lista squadra (`src/routes/squadra.tsx`)**
- Ridurre le icone badge sbloccati mostrate accanto al nome da `h-4 w-4` (16px) a `h-3 w-3` (12px).
- Mantenere il massimo di 3 badge visibili e il contatore `+N` con testo ridotto a `text-[9px]` per armonizzare.
- Lasciare invariati avatar, nome, ruolo ed età.
2. **Scheda giocatore espansa (`src/routes/squadra.tsx`)**
- Portare le icone badge dalla dimensione attuale a `h-4 w-4` (16px), leggibili ma non troppo grandi.
- Mantenere card, colori per grado (bronzo/argento/oro) e testi descrittivi.
3. **Verifica**
- Controllare la preview su `/squadra` per confermare che i badge in lista siano discreti e la scheda espansa rimanga leggibile.
- Eseguire build per assicurarsi che non ci siano errori di tipo o stile.
## Cosa non cambia
- Colori dei gradi, soglie badge, logica di sblocco e votazione MVP.
- Layout generale della pagina e bottom navigation.
@@ -1,14 +0,0 @@
# Rimuovere placeholder "Livello 7" dal Profilo
## Obiettivo
Eliminare il testo statico "Livello 7" dalla scheda profilo, dato che non è collegato a nessun calcolo reale e l'utente preferisce toglierlo per ora.
## Modifica
- `src/routes/profilo.tsx`: rimuovere il paragrafo `<p className="font-display text-2xl leading-none">Livello 7</p>` (riga 116) e, se necessario, riallineare il layout circostante per evitare spazi vuoti strani.
## Verifica
- Build senza errori.
- Preview della pagina Profilo: nessun riferimento a "Livello" visibile.
@@ -1,34 +0,0 @@
# Selezione giocatore al primo avvio
Aggiungere un flusso di onboarding che chiede "Chi sei?" la prima volta che l'app viene aperta, memorizzando la scelta in `localStorage`. Il profilo e la home si aggiorneranno automaticamente in base al giocatore selezionato.
## Cosa cambia
1. **Nuovo store `src/lib/user-store.ts`**
- Persiste in `localStorage` l'`id` del giocatore scelto.
- Espone `useGiocatoreCorrente()` che restituisce il giocatore selezionato o `null`.
- Espone `impostaGiocatore(id)` e `resetGiocatore()`.
2. **Nuova route `/benvenuto`**
- Schermata full-screen con logo, titolo "Benvenuto in CrAPP" e lista scrollabile della rosa.
- Ogni riga mostra iniziali, nome, ruolo e numero maglia.
- Al tap su un giocatore, lo store viene aggiornato e l'utente viene portato a `/`.
- Non mostra la bottom navigation.
3. **Reindirizzamento condizionato in `__root.tsx`**
- Se non è ancora stato selezionato un giocatore, qualunque route apre `/benvenuto`.
- Dopo la scelta, l'app funziona normalmente.
4. **Sostituzione di `giocatoreCorrente` con `useGiocatoreCorrente()`**
- Aggiornare `src/routes/index.tsx` per salutare il giocatore selezionato e mostrarne le statistiche rapide.
- Aggiornare `src/routes/profilo.tsx` per renderlo il profilo personale del giocatore scelto.
5. **Cambio utente dalle impostazioni**
- In `src/routes/profilo.tsx`, aggiungere una voce "Cambia giocatore" che resetta la selezione e porta a `/benvenuto`.
## Note tecniche
- `localStorage` viene letto solo lato client, usando `useSyncExternalStore` per evitare mismatch di hydration.
- La rosa reale è già presente in `src/lib/crapp-data.ts` (`giocatori`).
- La costante esportata `giocatoreCorrente` verrà rimossa; i componenti consumeranno il nuovo hook.
- Nessun backend richiesto: resta un prototipo locale.
@@ -1,45 +0,0 @@
# Turno palloni nel calendario + promemoria
Aggiungere a ogni allenamento/partita un incaricato dei palloni, condiviso tra tutti, con rotazione automatica proposta dall'app e promemoria (in-app e push).
## Cosa vedrà la squadra
- **Riga "Palloni" su ogni evento** (allenamento e partita) nella card del calendario e in Home: avatar + nome dell'incaricato, oppure "Da assegnare".
- **Chiunque può cambiarlo**: tocco sulla riga, si apre la lista della rosa, si sceglie il nome. La modifica è immediata e visibile a tutti.
- **Proposta automatica a rotazione**: l'app suggerisce chi non ha ancora fatto il turno di recente (o è stato meno volte incaricato). Il suggerimento è solo una proposta: resta sempre modificabile.
- **Storico turni** nella scheda squadra/giocatore: quante volte ciascuno ha portato i palloni.
- **Due promemoria per l'incaricato**:
1. il giorno stesso dell'evento in cui li deve **prendere** a fine allenamento/partita;
2. il giorno dell'evento successivo, per ricordargli di **riportarli**.
- I promemoria arrivano come banner ben visibile in Home e, per chi attiva le notifiche, come notifica push sul telefono.
## Impostazione tecnica
**Backend (Lovable Cloud)**
- Attivazione di Lovable Cloud.
- Tabella `eventi` (spostando i dati demo attuali su database) o, in alternativa minima, tabella `turni_palloni` con `evento_id`, `giocatore_id`, `aggiornato_da`, `aggiornato_il`. Scelgo la seconda per limitare il refactor: gli eventi restano in `crapp-data.ts` finché non si passa a calendario dinamico.
- Tabella `push_subscriptions` (giocatore_id, endpoint, chiavi) per le notifiche.
- Grant espliciti + RLS: lettura e scrittura aperte a tutti gli utenti dell'app (nessun login previsto oggi → policy per `anon` limitate a queste tabelle, nessun dato personale sensibile).
- Server functions in `src/lib/palloni.functions.ts`: `getTurni`, `setTurno`, `suggerisciTurno`.
**Frontend**
- Nuovo componente `TurnoPalloni` usato in `EventoCard` e in Home.
- Lettura via TanStack Query (`ensureQueryData` nel loader, `useSuspenseQuery` nel componente), invalidazione dopo la modifica.
- Banner promemoria in Home basato su data odierna + evento successivo, mostrato solo al giocatore selezionato in `user-store`.
**Notifiche push**
- Service worker dedicato al messaging (separato dalla PWA esistente), chiavi VAPID salvate come secret.
- Schermata in Profilo: "Attiva notifiche palloni" con richiesta di permesso.
- Invio schedulato tramite un endpoint `src/routes/api/public/promemoria-palloni.ts` protetto da secret, richiamato una volta al giorno da un job pianificato (pg_cron).
- Nota: su iPhone le notifiche push funzionano solo se l'app è installata dalla schermata Home.
## Ordine di lavoro
1. Attivare Lovable Cloud e creare tabelle + policy.
2. Server functions + UI del turno palloni (assegnazione manuale condivisa).
3. Rotazione automatica suggerita + storico turni.
4. Banner promemoria in-app.
5. Notifiche push + job giornaliero.
-5
View File
@@ -1,5 +0,0 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-9e5645c506e5"
}
+5 -5
View File
@@ -5,8 +5,8 @@ su questo repository. Valgono integralmente; `CLAUDE.md` le richiama e non le ri
CrAPP è una PWA per la gestione di una squadra di pallavolo. Deve ridurre il lavoro degli
amministratori, aumentare il coinvolgimento dei giocatori, centralizzare le informazioni della
squadra e usare l'AI solo quando porta un beneficio reale. Il perché sta in
[docs/VISION.md](docs/VISION.md).
squadra e usare l'AI solo quando porta un beneficio reale. Deve restare semplice, veloce e
usabile dallo smartphone anche da chi non è pratico.
## Prima di modificare il codice
@@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # suite di test (test/); npm run test:all per quella completa
@@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro.
Prima di dire che hai finito:
1. i test delle funzioni aggiunte o modificate esistono e sono verdi;
2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e);
3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità);
4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi.
@@ -83,8 +84,7 @@ conversazioni: commit con messaggio descrittivo, più il documento giusto tra
[docs/CHANGELOG.md](docs/CHANGELOG.md) (cosa è stato rilasciato e quando),
[PROJECT_STATE.md](PROJECT_STATE.md) (stato generale del progetto),
[docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md) (decisioni architetturali, voci `DD-XXX`),
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/TODO.md](docs/TODO.md),
[docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
[docs/ROADMAP.md](docs/ROADMAP.md), [docs/DATABASE.md](docs/DATABASE.md) (se cambia lo schema).
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
+89 -8
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 04/09/2026
Ultimo aggiornamento: 29/09/2026
## Stato generale
@@ -10,7 +10,12 @@ 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).
reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0, ha
portato il ruolo allenatore. La versione corrente è la 1.2.1: notifiche su tre canali (push, in-app
ed email), promemoria che non si ripetono, turno palloni e solleciti presenze automatici (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).
---
@@ -21,7 +26,12 @@ reali (M9).
- Cursor e Claude Code come ambienti di sviluppo
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
- 20 migration in `supabase/migrations/`, fino a `m9_risposte_presenze_risposto_il`
- 37 migration in `supabase/migrations/`, fino a `m26_notifiche_automatiche` (30/09/2026), tutte
applicate in produzione, verificato con `npx supabase migration list` il 30/09/2026
- Worker delle notifiche email (`mailer/`) in funzione su un Raspberry Pi con Docker dal
29/09/2026: invia via Gmail le notifiche di `notifiche_utente`. Installazione e gestione in
[docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md); il codice con l'interruttore «Email» in Profilo è
su `develop`, non ancora su `main`
- Sviluppo locale verificato con il nuovo Supabase
---
@@ -36,7 +46,52 @@ reali (M9).
## Database
- Schema v1.0 e migration da M1 a M9 applicate al nuovo Supabase
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
`npx supabase migration list`)
- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente`
per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca
schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche,
route aggiornate) non è ancora deployato: la tabella si riempie già in background
- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna
`giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge
l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del
seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed
storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto
- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il
backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata
in `npx supabase migration list` del 24/09/2026)
- Migration `m25_promemoria_solo_giocatori` (29/09/2026, DD-039): `giocatori_destinatari_evento()`
include solo i giocatori attivi, quindi l'allenatore non riceve più i promemoria degli eventi
(li riceveva sempre dalla `m21`). Cambia solo quella funzione; verificata in locale con
`npx supabase db reset` e i test di integrazione, **non ancora in produzione**
- Migration `m24_push_promemoria_e_sondaggio` (29/09/2026, DD-038): tabella `notifiche_push_coda`
con trigger e due funzioni per il worker (la push dei promemoria a 24 e 3 ore), e nuovo tipo
`sondaggio_cacche` in `notifiche_utente` (il sondaggio pre-partita compare in-app e per email).
Additiva; verificata in locale con `npx supabase db reset`, i test di integrazione e una prova
end-to-end del worker con un servizio push finto, **non ancora in produzione**. Richiede il
worker ricostruito e le chiavi VAPID nel suo `.env` (vedi `docs/WORKER_EMAIL.md`)
- Migration `m23_promemoria_gia_generati` (29/09/2026, DD-037): tabella
`promemoria_eventi_generati` e nuova `genera_promemoria_eventi()`, così un promemoria a 24 o 3
ore eliminato dal centro notifiche non viene più rigenerato dal cron (e non riaccoda mail).
Corregge un difetto presente dalla `m17`, emerso con l'arrivo delle email. Additiva; verificata
in locale con `npx supabase db reset` e `test/integration/promemoria-eventi.test.ts`, **non
ancora in produzione**
- Migration `m22_notifiche_email` (29/09/2026, DD-036): tabelle `notifiche_email_coda` e
`preferenze_utente`, trigger che accoda ogni notifica di `notifiche_utente` e tre funzioni
RPC riservate alla service role, per il worker email. Additiva: non tocca lo schema
esistente. Verificata in locale con `npx supabase db reset`, test unit e di integrazione e
una prova end-to-end con il worker in Docker contro Mailpit, poi **in produzione** dal
29/09/2026 (`supabase db push`) con il worker in funzione
- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo
allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e
trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in
produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori
registrati non cambiano nulla. Il codice che le usa non è ancora deployato
- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna
`tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore»
lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare
da `/admin` dopo il deploy del codice nuovo
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
@@ -48,10 +103,31 @@ reali (M9).
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
---
@@ -59,13 +135,18 @@ reali (M9).
- Squadra
- Presenze
- Badge
- 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`)
---
@@ -125,9 +206,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
Niente di assegnato: la v1.1 è completa, tesseramento CSI incluso (numero e data di tessera
registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md).
Niente di assegnato: l'avviso certificati in scadenza (DD-035, 1.1.0), le notifiche su tre
canali (DD-036–DD-039, 1.2.0) e quelle automatiche di palloni e solleciti (DD-040, 1.2.1) sono implementati (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
---
+1
View File
@@ -34,6 +34,7 @@ npm run dev # http://localhost:8080
```bash
npm run build # build di produzione
npm run lint # eslint (include prettier)
npm run typecheck # controllo dei tipi TypeScript
npm run test # test unit; npm run test:all per la suite completa
```
+132
View File
@@ -0,0 +1,132 @@
# Backup di disaster recovery
Script per scaricare su questo host un backup completo del progetto Supabase:
schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta
se il progetto Supabase (o Vercel) venisse perso.
## Preparazione, passo per passo
Da fare una volta sola per host (poi il backup si rilancia semplicemente con
`./backups/backup.sh`).
1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte):
```bash
npx supabase --version
```
2. **Accedi con il tuo account Supabase**:
```bash
npx supabase login
```
Si apre il browser per l'OAuth; il token resta salvato in locale.
3. **Collega il repo al progetto Supabase** (il `project_id` è già in
`supabase/config.toml`, quindi in genere basta):
```bash
npx supabase link --project-ref <project_id>
```
Verifica che sia andato a buon fine con `npx supabase projects list`
(il progetto deve comparire con `"linked":true`).
4. **Crea/controlla il file `.env`** nella root del repo (accanto a
`package.json`, non dentro `backups/`) con almeno queste due chiavi:
```
VITE_SUPABASE_URL="https://<project_id>.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="<service role key>"
```
Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto →
Project Settings → API. La **service role key** è quella con permessi pieni
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
esposta lato client né committata.
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
```bash
gpg --version
```
Su Debian/Ubuntu: `sudo apt install gnupg` se manca.
A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
## Uso
```bash
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
# passphrase a te scelta) e cancella cartella+tar.gz
# in chiaro
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
# (nessuna passphrase richiesta)
```
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
mano quando non servono più.
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
incompleto non va considerato valido, rilancialo.
Il backup contiene:
| File/cartella | Contenuto |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) |
| `data_public.sql` | dati delle tabelle applicative |
| `schema_auth.sql` | schema utenti Supabase Auth |
| `data_auth.sql` | utenti reali, incluso l'hash della password |
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
## Decomprimere
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
del backup:
```bash
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
```
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
Archivio non cifrato (generato con `--no-encrypt`):
```bash
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
```
## Ripristino
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` →
`data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati
pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`).
3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il
contenuto di `storage/`.
4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo
backup — vedi sotto).
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
GitHub e Gitea).
## Cosa NON è incluso
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
automatizzabile da questo script. Esportale a mano con `vercel env pull`.
- **Codice applicativo**: vive nel repo git, non nel database.
## Sicurezza
Questi backup contengono password hash, documenti d'identità e altri dati
personali reali. Per questo:
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
quelli sì restano in git) — **non committare mai i dati generati**;
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
invece del tar.gz in chiaro generato con `--no-encrypt`;
- cancella i backup in chiaro quando non ti servono più.
## File di questa cartella
| File | Ruolo |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `backup.sh` | Punto di ingresso: esegue dump e download in ordine |
| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) |
| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` |
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
# Uso:
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
#
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
# installato (esegue download-storage.mjs, che importa un modulo .ts).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAMP="$(date +%Y%m%d_%H%M)"
RUN_DIR="$SCRIPT_DIR/$STAMP"
mkdir -p "$RUN_DIR"
echo "==> Backup in $RUN_DIR"
echo "==> Dump schema pubblico"
npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql"
echo "==> Dump dati pubblici"
npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql"
echo "==> Dump schema auth"
npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql"
echo "==> Dump dati auth (utenti)"
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
echo "==> Download file storage (bucket)"
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
echo "==> Backup completato: $RUN_DIR"
TARBALL="$RUN_DIR.tar.gz"
echo "==> Comprimo in $TARBALL"
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
if [[ "${1:-}" == "--no-encrypt" ]]; then
echo "==> Archivio pronto: $TARBALL"
else
ARCHIVE="$TARBALL.gpg"
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
rm -rf "$RUN_DIR" "$TARBALL"
echo "==> Archivio cifrato pronto: $ARCHIVE"
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
fi
+45
View File
@@ -0,0 +1,45 @@
// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery.
// Uso: node backups/download-storage.mjs [cartella-output]
import { createClient } from "@supabase/supabase-js";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { elencaFile, leggiEnv } from "./storage-core.ts";
const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8"));
for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore;
const BUCKETS = ["avatar-giocatori", "profili-giocatore"];
const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname;
const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const elencaCartella = (bucket) => async (prefisso) => {
const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 });
if (error) throw error;
return data;
};
const falliti = [];
for (const bucket of BUCKETS) {
console.log(`Bucket: ${bucket}`);
const files = await elencaFile(elencaCartella(bucket));
console.log(` ${files.length} file trovati`);
for (const path of files) {
const { data, error } = await supabase.storage.from(bucket).download(path);
if (error) {
console.error(` ERRORE ${path}:`, error.message);
falliti.push(`${bucket}/${path}`);
continue;
}
const destPath = join(OUT_DIR, bucket, path);
await mkdir(dirname(destPath), { recursive: true });
await writeFile(destPath, Buffer.from(await data.arrayBuffer()));
console.log(` scaricato: ${bucket}/${path}`);
}
}
if (falliti.length) {
console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`);
process.exit(1);
}
console.log("Fatto.");
+30
View File
@@ -0,0 +1,30 @@
/** Logica pura del backup storage, separata dalla rete per poterla testare. */
/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */
export function leggiEnv(testo: string): Record<string, string> {
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const match = /^([\w.]+)=(.*)$/.exec(riga.trim());
if (!match) continue;
const [, chiave, grezzo] = match as unknown as [string, string, string];
const tra = /^(["'])(.*)\1$/.exec(grezzo);
env[chiave] = tra ? tra[2]! : grezzo;
}
return env;
}
export type Voce = { name: string; id: string | null };
/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */
export async function elencaFile(
lista: (prefisso: string) => Promise<Voce[]>,
prefisso = "",
): Promise<string[]> {
const file: string[] = [];
for (const voce of await lista(prefisso)) {
const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name;
if (voce.id === null) file.push(...(await elencaFile(lista, percorso)));
else file.push(percorso);
}
return file;
}
+5 -16
View File
@@ -3,9 +3,8 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "tanstack_start_ts",
"name": "crapp",
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -27,7 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
@@ -130,14 +129,6 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lovable.dev/cloud-auth-js": ["@lovable.dev/cloud-auth-js@1.1.2", "https://europe-west4-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/cloud-auth-js/-/cloud-auth-js-1.1.2.tgz", {}, "sha512-xz8ocewsgwkp8giau272/eWWU3XrchCg5uba4yQPPYtevHTXaVU3sD+fO1JjyPBHacVcOcwhmgUiU9TKHt63cg=="],
"@lovable.dev/vite-plugin-dev-server-bridge": ["@lovable.dev/vite-plugin-dev-server-bridge@1.2.1", "", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-JdADRwpEJA5t0ggXbd96dND23Wsp69Af9lVSV5m7MnMGxJZlPgqAqz7HUKcfJESfI5M5yFZOl9e0x76JMMDOAg=="],
"@lovable.dev/vite-plugin-hmr-gate": ["@lovable.dev/vite-plugin-hmr-gate@1.3.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-plugin-hmr-gate/-/vite-plugin-hmr-gate-1.3.5.tgz", { "peerDependencies": { "vite": ">=5.0.0 <9.0.0" } }, "sha512-LxCj6JIbYRQ6peMm2aVn/bhHLkwZ5eZ4Cn6gmh7LfjaplqZHlA24nhCowgt6ZLfbckc4d8tSy0He7H8EiFEXag=="],
"@lovable.dev/vite-tanstack-config": ["@lovable.dev/vite-tanstack-config@2.8.5", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/@lovable.dev/vite-tanstack-config/-/vite-tanstack-config-2.8.5.tgz", { "dependencies": { "@lovable.dev/vite-plugin-dev-server-bridge": "^1.2.1", "@lovable.dev/vite-plugin-hmr-gate": "^1.3.4", "@tanstack/devtools-vite": "^0.8.1", "lightningcss": "^1.30.0" }, "peerDependencies": { "@tailwindcss/vite": ">=4.0.0", "@tanstack/react-start": ">=1.100.0", "@vitejs/plugin-react": ">=4.0.0", "nitro": ">=3.0.260603-beta", "vite": ">=5.0.0 <9.0.0", "vite-tsconfig-paths": ">=6.0.0" }, "optionalPeers": ["nitro"] }, "sha512-qPNxEXjvRsrbJHKgHxuLWpRW/gu2nIM+62qjTUCbCno5nRPMKyJQNsgjh48qjAkS6T0MRIkY/3BENNXeGsi5hw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.0", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^2.0.0-alpha.3", "@emnapi/runtime": "^2.0.0-alpha.3" } }, "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
@@ -424,7 +415,7 @@
"canvas-confetti": ["canvas-confetti@1.9.4", "https://europe-west1-npm.pkg.dev/lovable-core-prod/sandbox-npm-cache/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -800,10 +791,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/devtools-bundler-core/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/devtools-vite/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@tanstack/router-generator/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@tanstack/router-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
@@ -820,6 +807,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"h3/srvx": ["srvx@0.12.4", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RixzFlMn3dvzDTpKIAXhXrqL4cy6vScNCP0VVwgVbUBU94o+DiXLsFnAZetbyKAEnK6Ox3hzHXWGsyv/Iibv7g=="],
"h3-v2/rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
+1 -1
View File
@@ -4,4 +4,4 @@ saveTextLockfile = true
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]
minimumReleaseAgeExcludes = []
+4 -2
View File
@@ -60,8 +60,9 @@ ricorrente:
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
disattivati, `retry: 1`);
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
eccezione oggi: `scout-live.ts`);
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più. Unica
eccezione: `scout-live.ts`, dove il lock può essere stato preso da un altro dispositivo,
quindi quello che abbiamo scritto non è detto sia quello che vale;
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
@@ -107,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a
npm run dev # vite dev su http://localhost:8080
npm run build # build di produzione (nitro)
npm run lint # eslint (include prettier come regola)
npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file)
npm run format # prettier --write .
npm run test # test unit (veloci, senza rete né database)
npm run test:integration # route server vere
+241 -167
View File
@@ -1,200 +1,274 @@
# Changelog
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
qui: sta in [ROADMAP.md](ROADMAP.md).
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
registra solo _quando_ una voce è stata rilasciata e con quale versione.
## Versione attuale — agosto 2026
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il
[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non
compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le
versioni `0.y.z` erano pre-release.
### Lo Scout Live si apre dalla pagina della partita
## [Non rilasciato]
- Tolto dalla home, era rimasto senza nessun link: `/scout` si raggiungeva solo scrivendo
l'URL. Ora la card `ScoutEntry` sta in `/partita/$id`, sezione «Scout live».
- Si accende solo se la partita aperta è quella di oggi (nuova prop `eventoId`), altrimenti
resta grigia con «Si attiva il giorno della partita». Lock di sessione invariato.
- Non è più riservato agli admin: può scoutare chiunque sia autenticato, uno per volta grazie
al lock. Sparisce il messaggio «Scout riservato».
## [1.2.1] - 2026-09-30
### Il sondaggio pre-partita apre alle 8:00 del giorno della partita
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.
- Prima era sempre votabile, anche settimane prima: ora la card resta chiusa con l'avviso di
apertura e si sblocca alle 8:00 del giorno stesso.
- Quando è aperto, gli amministratori hanno nella card il pulsante «Avvisa tutti del
sondaggio» (`POST /api/public/apri-sondaggio`), che manda la push a tutti i dispositivi
iscritti — stesso meccanismo del sollecito presenze. Nessun cron: l'invio è manuale.
### Aggiunto
### Le note dell'evento si leggono aprendolo
- **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 `DESIGN_DECISIONS.md`; sezione «Testi delle notifiche» nel catalogo di
`modules/notifiche.md`; tabella delle funzioni SQL e dei job in `DATABASE.md`.
- Il campo Note del form eventi si poteva scrivere ma non lo vedeva nessuno: ora compare
nella scheda di `/allenamento/$id` e `/partita/$id`, sotto orario e luogo, con gli a capo
mantenuti. Se è vuoto non compare niente.
### Modificato
### Form eventi: data e ora non sfondano più la card su iOS
- **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.
- Lo stesso difetto già corretto sul tesseramento: `Campo` e le classi degli input erano
ricopiati identici in tre file, quindi il `min-w-0` aggiunto in `ProfiloAmministrativo`
non arrivava né al form «Nuovo evento» né alla dashboard admin. Ora `Campo` e
`classiInput` stanno una volta sola in `ui-bits`.
- La regola CSS che rende ridimensionabili i controlli nativi copre anche
`input[type="time"]`, che nel form eventi sta affiancato alla data in `grid-cols-2`.
### Corretto
### Profilo: tab Documenti e Opzioni, barra senza scroll
- `PROJECT_STATE.md` dava `m23`–`m25` come non applicate in produzione: lo sono dal 29/09/2026.
- L'etichetta nominava lo scopo (il tesseramento CSI) invece del contenuto: dentro ci sono
dati personali, documento, certificato medico e foto tessera. Cambia anche la rotta
(`/profilo?tab=documenti`): i vecchi link `?tab=tesseramento` aprono la tab Stagione.
- «Impostazioni» diventa «Opzioni»: con le quattro etichette accorciate la barra delle
sottosezioni ci sta in uno schermo da telefono. Le voci ora si dividono la riga in parti
uguali (`grow basis-0`) e tornano a scorrere solo se non ci stanno, quindi vale anche per
le barre di Squadra e Classifica.
## [1.2.0] - 2026-09-29
### Palloni: allenamenti senza proposta automatica
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.
- `completaTurni` non assegna più gli allenamenti: restano «da assegnare» finché non si
sceglie a mano. Le partite tengono la rotazione. Migration M10 cancella eventuali turni
salvati su allenamenti da oggi in poi.
### Aggiunto
### Profilo: etichetta notifiche allineata al comportamento
- **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.
- L’interruttore in Impostazioni non è più «Notifiche turno palloni»: iscrive il dispositivo
a tutte le push (palloni, solleciti) e alle smart in app. Testo e docs aggiornati.
- Il widget Home «Completa il tuo profilo» apre direttamente la tab Tesseramento
(`/profilo?tab=tesseramento`).
### Modificato
### Revisione dell'interfaccia: accessibilità, movimento, peso
- **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.
- **Contrasto**: `--success`, `--info` e `--training` erano tra 3.3:1 e 3.5:1 con il testo
bianco sopra (chip «Presente», «Allenamento», celle del calendario): ora sono sotto la
soglia di luminosità che garantisce 4.5:1. I gradi dei badge usavano `text-oro` e
`text-argento` su bianco, cioè 1.9:1 e 2.5:1 — praticamente invisibili: nascono i token
`--oro-testo`, `--argento-testo`, `--bronzo-testo` per il testo, mentre le versioni chiare
restano su sfondi e bordi.
- **PWA**: mancava `viewport-fit=cover`, quindi `env(safe-area-inset-bottom)` valeva sempre 0
e su iPhone la BottomNav finiva sotto la home bar. `theme-color` e `background_color` erano
`#111111` su un'app chiara: barra di stato nera e splash nero prima di una UI bianca.
- **`lang="it"`** al posto di `lang="en"`, su un'app interamente in italiano; 404 e schermata
d'errore tradotte; anteprima social ripulita dall'immagine Lovable scaduta e da
`twitter:site` che puntava a `@Lovable`.
- **Tocco e tastiera**: nessun `:focus-visible` era definito (ora c'è una regola globale);
chip presenza, filtri e bottoni icona portati a 44px; `aria-current` sulla navigazione,
`aria-pressed` sui controlli a stato, `aria-controls` sulle sezioni a tendina, `aria-busy`
sui caricamenti. Il testo sotto i 12px è sparito (108 occorrenze).
- **Movimento** (DD-021): molle interrompibili di `motion` al posto delle `@keyframes` a
durata fissa. `Reveal` compare quando entra davvero nel viewport (prima consumava
l'animazione a vuoto sotto la piega); `Barra` anima `scaleX` invece di `width`; `Numero`
cambia rotta se il dato cambia a metà conteggio. Il calendario si cambia mese anche con lo
swipe, con il punto d'arrivo scelto proiettando la velocità di rilascio.
- **Meno dipendenze** (DD-021): rimossi 43 componenti `src/components/ui/` non importati da
nessuna parte e ~45 dipendenze (tutti i `@radix-ui/*`, `recharts`, `react-hook-form`,
`date-fns`, `embla`, `cmdk`; `zod` resta perché lo usano le route API). Restano `drawer` e
`sonner`. Il bundle **non** cala per questo — quel codice era già escluso dal
tree-shaking — ma cala la superficie da aggiornare e da controllare: 50 dipendenze dirette
diventano 21. Il bundle client cresce di ~42 KB gzip per `motion` (267 → 308 KB).
- **Primitiva `Card`**: `rounded-3xl bg-card p-4 shadow-card` era ricopiato a mano 22 volte.
- **Tema scuro rimosso** (DD-022): esisteva un blocco `.dark` mai applicato e incoerente.
- Tolte le quattro switch di notifica in `/profilo` che erano `defaultChecked` e non facevano
niente, e la conferma nativa prima di _cambiare_ la foto profilo (resta su quella che la
rimuove, che è irreversibile).
- Le celle del calendario con più tipi di evento non usano più un gradiente a fette con
un'ombra bianca sul numero per restare leggibili: fondo neutro e un puntino per tipo.
### Corretto
### Serie di presenze calcolate sui dati reali
- **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.
- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e
`risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze»
del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra»
che ne dipendono.
- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante
della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo
l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento).
Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge
"Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui
in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)).
- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il
successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%,
poi 3/6 = 50%).
## [1.1.1] - 2026-09-28
### Segnalazioni dal profilo
### Aggiunto
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo` → Impostazioni: due
link che aprono una issue GitHub sul template giusto
(`.github/ISSUE_TEMPLATE/bug_report.yml`, `feature_request.yml`). Nessuna tabella e nessuna
schermata di gestione: la segnalazione vive su GitHub
(vedi [modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
pausa dopo 7 giorni senza richieste API.
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
pertinente.
### Autenticazione e dashboard amministratore (in produzione)
### Modificato
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
giocatore stesso (DD-016 regola 2).
- La selezione libera del giocatore è stata rimossa: `/benvenuto` offre solo l'accesso con
Google e senza sessione non si entra in nessuna schermata. Sparita anche la variabile
`VITE_AUTH_OBBLIGATORIA` (non serve più) e il pulsante «Cambia giocatore» in `/profilo`.
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): la lista
di nomi in `crapp-data.ts` è stata eliminata, altrimenti bastava scegliere il nome giusto
per amministrare.
- Migration `m4_solo_autenticati`: toglie al ruolo `anon` l'accesso alle tabelle v1.0.
Applicata in produzione il 03/09/2026, dopo aver impostato l'email di tutta la rosa
attiva — il login era già l'unica via d'accesso lato app, quindi il collegamento dei
singoli account (che resta un processo continuo a ogni login) non era comunque
condizionato da questa migration.
- Collegamento automatico al proprio giocatore per email (DD-018, migration
`m5_email_giocatori_squadra`): niente più scelta manuale da un elenco, `/benvenuto`
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
riprovare con un altro account.
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni
giocatore già in rosa.
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
documento, certificato medico e foto tessera con le relative scadenze
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
certificato e foto tessera, export CSV per il tesseramento CSI.
- Migration `m2_profili_giocatore` (tabella dei profili e bucket privato), additiva.
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
compila i dati personali al posto di un giocatore e scollega un account da un profilo
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
policy di M1 e M2 lo consentivano già.
- Foto profilo sincronizzate tra dispositivi: da `/profilo` la foto caricata finisce nel
bucket pubblico `avatar-giocatori` (migration `m6_avatar_giocatori`) invece che in
`localStorage`, così compare per tutta la squadra e non solo su chi l'ha caricata.
L'Avatar mostra numero/iniziali finché la foto non è presente.
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" ora passa dalla
tabella `scout_sessioni` invece che da `localStorage`, quindi due telefoni non possono più
prendere il controllo insieme sovrascrivendosi a vicenda. Le partite scoutate concluse
vengono archiviate nella nuova tabella `scout_partite` (migration `m7_scout_partite`):
prima restavano visibili solo sul telefono di chi aveva chiuso la partita.
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
di decompressione nel README.
### Test
### Corretto
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun,
senza nuove dipendenze: `npm run test` e `npm run test:all`.
- Corretto un difetto emerso dai test: una sessione Scout Live con timestamp
illeggibile restava bloccata per sempre invece di scadere.
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
mostrare subito tutto.
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
il cambio tab verso Obiettivi.
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
sono allenamenti o partite.
### Collegamento CSI
## [1.1.0] - 2026-09-27
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
- La pagina Campionato non usa più dati dimostrativi.
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
### Aggiunto
### Infrastruttura
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
nessuna push (DD-035).
- Migrazione completa da Lovable a sviluppo locale.
- Configurazione Git.
- Repository GitHub indipendente.
- Deploy automatico tramite Vercel.
- Branch main e develop.
## [1.0.0] - 2026-09-24
## Versione 1.0 — luglio 2026
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario.
Prima versione usata dalla squadra. Funzionalità incluse: vedi
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
### Aggiunto
- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con
l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e
sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e
badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche.
Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034).
- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla
push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno
palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration
`m17_notifiche_utente` (DD-030).
- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio
libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le
notifiche attive.
- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per
vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica
di un evento esistente compare una conferma, come per l'eliminazione.
- **Backup completo** — uno script scarica schema e dati del database e i file dello
storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`).
### Modificato
- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del
profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco
(leggi ed elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
mensile, che si scorre anche con lo swipe come quello della squadra.
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo
«Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale».
### Corretto
- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra
(e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili
compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e
`m19_backfill_nascita_da_profili_esistenti` (DD-031).
- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano.
## [0.9.2] - 2026-09-10
### Aggiunto
- **Dashboard amministratore** — nuova tab "Notifiche" che mostra quanti giocatori hanno
le notifiche push attive e chi sono.
### Modificato
- **Dashboard amministratore** — le sezioni impilate diventano un menu di tab scorrevole a
pillole (come Squadra e Campionato); nell'elenco Profili resta aperta una sola scheda
alla volta.
- **Profilo** — testi dei campi amministrativi semplificati (label email, rimossa la nota
su chi vede quei dati).
### Rimosso
- Le dipendenze e il codice legati all'editor Lovable (login social e reporting errori
verso l'editor): l'app non ci gira più.
## [0.9.1] - 2026-09-10
### Modificato
- **Storico partite** — ogni scheda mostra il logo accanto al nome di entrambe le squadre
(CRAP e avversario), risultato e parziali in ordine casa–ospite (verde/rosso restano
vittoria/sconfitta CRAP) e un chevron a destra per chiarire che la riga apre il dettaglio.
## [0.9.0] - 2026-09-09
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
questa release riunisce tutto ciò che l'app fa oggi in produzione.
### Aggiunto
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
certificati resta un'estensione futura.
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
squadra al posto di chat e fogli condivisi.
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
reali della rosa.
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
durante la partita.
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
votati tra compagni.
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
compagni, un voto a testa.
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
convocazioni, pagelle e risultati di campionato.
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
allenamenti e partite.
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
dati della rosa, con download CSV.
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
manuale da parte degli amministratori.
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
presenza già registrato per le convocazioni.
### Sicurezza
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
+68 -22
View File
@@ -5,14 +5,39 @@ sono le migration in `supabase/migrations/`: **una tabella nuova va documentata
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
non in questo file.
## Permessi di scrittura
Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo
allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a
nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
## Anagrafica e utenti
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). |
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
@@ -25,12 +50,12 @@ non in questo file.
## Eventi e presenze
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
| Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
## Scout
@@ -42,19 +67,40 @@ non in questo file.
## Votazioni
| Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
| `badge_social_voti` | Voti social per i badge. | |
| 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`). |
## 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` | Storico dei promemoria inviati. | |
| Tabella | Scopo | Note |
| ---------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. |
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. |
| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Cinque sorgenti (le prime quattro dalla `m17`, il sondaggio dalla `m24`), tutte in parallelo alla push esistente tranne i promemoria, la cui push la manda il worker (`notifiche_push_coda`): messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`) e il sondaggio pre-partita (`apri-sondaggio.ts`, tipo `sondaggio_cacche`, `m24`), questi ultimi tre avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. I promemoria evento arrivano solo ai giocatori attivi: ai convocati, o a tutti se `convocati` è vuoto (`giocatori_destinatari_evento()`, riscritta da M21 per includere sempre gli allenatori e da M25 per escluderli, DD-039); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. Migration `m26_notifiche_automatiche` (DD-040): il vincolo `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job; `turno_palloni` e `sollecita_presenze` restano i tipi dei pulsanti. Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi automatici ma non i due dei pulsanti. |
| `notifiche_email_coda` | Coda delle email per le notifiche in-app: una riga per notifica, con stato d'invio. | Migration `m22_notifiche_email` (DD-036). Chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`. Stati: `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`; colonne `tentativi`, `prossimo_tentativo` (backoff), `errore` (solo codici SMTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`): ogni notifica nuova e ogni rinvio via `upsert` finiscono in coda; le notifiche presenti prima della migration no. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`: la usa solo il worker `mailer/` con la service role, tramite tre funzioni `SECURITY DEFINER` riservate a `service_role` — `prendi_notifiche_email(p_max)` (lotto con `FOR UPDATE SKIP LOCKED`, salta gli slot senza account o email o con le email spente, recupera le righe ferme `in_invio` da più di 10 minuti), `esito_notifica_email(...)` e `email_inviate_ultime_24h()`. Tabella separata da `notifiche_utente` perché la policy `UPDATE` del giocatore su quella riga gli permetterebbe di riportare a `in_coda` una mail già partita. Specifica in [notifiche-email.md](modules/notifiche-email.md). Con DD-040 (migration `m26`) il trigger salta i tipi del turno palloni, che non mandano email. |
| `preferenze_utente` | Preferenze del singolo account (non dello slot giocatore). | Migration `m22_notifiche_email`. Chiave `auth_user_id` → `auth.users(id)` con cascade; oggi una sola colonna, `email_notifiche` (default `true`), più `aggiornata_il`. **Nessuna riga = email attive**: la riga nasce solo quando il giocatore tocca l'interruttore «Email» in Profilo → Opzioni (upsert da `useEmailNotifiche()`, `src/lib/preferenze-utente.ts`). RLS: ognuno legge, inserisce e aggiorna solo la propria riga. |
| `promemoria_eventi_generati` | Registro dei promemoria evento (24 e 3 ore) già generati, per giocatore, evento e tipo. | Migration `m23_promemoria_gia_generati` (DD-037). Chiave `(giocatore_id, evento_id, tipo)` con `ON DELETE CASCADE` verso `giocatori_squadra` ed `eventi_app` (sparisce con l'evento, come le altre tabelle collegate, DD-029); `generato_il`. Scritta solo da `genera_promemoria_eventi()`, che crea la notifica in `notifiche_utente` soltanto per le righe appena registrate: eliminare la notifica dal centro notifiche non fa rigenerare il promemoria né accoda una nuova mail. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. La migration registra come già generati i promemoria presenti al momento dell'applicazione. Con DD-040 (migration `m26`) il `CHECK` sul tipo ammette anche i tipi dei palloni (12h, 6h, 3h, revocato) e dei solleciti (24h, 12h, 6h); è anche la memoria che permette la revoca. |
| `notifiche_push_coda` | Coda delle push per i promemoria a 24 e 3 ore: una riga per notifica, con stato d'invio. | Migration `m24_push_promemoria_e_sondaggio` (DD-038). Stessa struttura di `notifiche_email_coda`: chiave `notifica_id` → `notifiche_utente(id)` con `ON DELETE CASCADE`, stati `in_coda`, `in_invio`, `inviata`, `fallita`, `saltata`, `tentativi`, `prossimo_tentativo`, `errore` (solo codici HTTP), `inviata_il`, `aggiornata_il`. La riempie il trigger `notifiche_utente_accoda_push_promemoria` (`AFTER INSERT`, solo tipi `evento_promemoria_24h` e `evento_promemoria_3h`: le altre notifiche mandano la push dall'app; nessun backfill). Le usa solo il worker `mailer/` con la service role, tramite `prendi_push_promemoria(p_max)` (lotto con `FOR UPDATE SKIP LOCKED` con testo e dispositivi iscritti del giocatore; salta i giocatori senza dispositivi e i promemoria creati da oltre 3 ore, recupera le righe ferme `in_invio` da più di 10 minuti) ed `esito_push_promemoria(...)`, entrambe `SECURITY DEFINER` riservate a `service_role`. RLS attiva **senza policy** e permessi tolti a `anon`/`authenticated`. Con DD-040 (migration `m26`) accoda anche turno palloni e solleciti presenze. |
### 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. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26), con `genera_avvisi_palloni_fascia()` | Avvisi del turno palloni a 12, 6 e 3 ore 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), con `..._fascia()` | Solleciti a 24, 12 e 6 ore ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Funzioni speciali
+1157 -47
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -11,8 +11,9 @@ Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ot
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi:
`src/lib/scout-live.ts`.
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione:
`src/lib/scout-live.ts`, dove il lock di sessione può essere stato preso da un altro
dispositivo e la riga scritta non basta a sapere chi ha vinto.
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
+19 -10
View File
@@ -5,16 +5,16 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
## Stato attuale
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------ |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| `src/integrations/lovable/*` | Opzionale | Login social gestito da Lovable: **non importato da nessuna schermata**, rimovibile senza impatti. |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
| Componente | Portabile? | Note |
| ------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Frontend (React + TanStack Start/Router) | Sì | Build Vite standard, deploy su qualsiasi host Node. |
| Server functions / route API (`src/routes/api/*`) | Sì | API HTTP standard, nessuna edge function proprietaria. |
| Database | Sì | PostgreSQL puro; lo schema sta nelle migrazioni SQL. |
| Client dati (`@supabase/supabase-js`) | Sì | Supabase è open source e self-hostable; in alternativa si sostituisce il solo livello dati (`src/lib/*.ts`). |
| Web Push (`src/lib/webpush.server.ts`) | Sì | VAPID implementato con Web Crypto, disponibile in Node 18+. |
| Auth | Sì | GoTrue self-hosted oppure qualsiasi provider OIDC. |
| Worker email (`mailer/`) | Sì | Container su qualsiasi host con Docker; parla solo con Supabase (RPC), con un server SMTP a scelta e con i servizi push dei browser ([WORKER_EMAIL.md](WORKER_EMAIL.md)). |
| `@lovable.dev/vite-tanstack-config` | Solo build | Preset Vite; sostituibile con una config Vite/TanStack esplicita. |
## Regole da rispettare nelle prossime modifiche
@@ -28,6 +28,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud.
5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`),
così funzionano con cron di sistema, pg_cron o qualsiasi scheduler.
## Backup e disaster recovery
Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e
dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in
modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso.
Uso, contenuto del backup e procedura di ripristino sono descritti in
[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle
variabili d'ambiente Vercel (richiede login interattivo).
## Migrazione su server proprio (sintesi)
1. `bun run build` (o `npm run build`) → avvio del server Node generato.
+17 -17
View File
@@ -6,35 +6,35 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi
## Dove sta cosa
| Documento | Risponde a |
| ------------------------------------------ | ---------------------------------------------------------------------------- |
| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [TODO.md](TODO.md) | A cosa si sta lavorando adesso |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
| Documento | Risponde a |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea |
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) |
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
| [WORKER_EMAIL.md](WORKER_EMAIL.md) | Come si installa, si gestisce e si diagnostica il worker che invia le notifiche per email |
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
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).
## Ordine di lettura
Prima di modificare il codice, nell'ordine: questo indice → `VISION.md` → `ROADMAP.md` →
`ARCHITECTURE.md` → `DATABASE.md` → `DESIGN_DECISIONS.md` → `TODO.md` → il documento del
modulo interessato in `modules/`.
Prima di modificare il codice, nell'ordine: questo indice → `ROADMAP.md` → `ARCHITECTURE.md`
→ `DATABASE.md` → `DESIGN_DECISIONS.md` → il documento del modulo interessato in `modules/`.
## Regole di manutenzione
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco;
- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap;
- `CHANGELOG.md` registra _quando_ qualcosa è stato rilasciato, non ripete l'elenco: segue
il formato Keep a Changelog, con il lavoro non ancora rilasciato sotto `[Non rilasciato]`
e le voci divise per categoria (Aggiunto, Modificato, Sicurezza…);
- il lavoro in corso sta solo in `PROJECT_STATE.md`, che rimanda alla roadmap per il resto;
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
+34 -22
View File
@@ -1,10 +1,12 @@
# Roadmap
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata, `TODO.md` cosa si
sta facendo adesso.
il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con quale versione,
[PROJECT_STATE.md](../PROJECT_STATE.md) cosa si sta facendo adesso.
## Versione 1.0 — rilasciata
## Fatto
Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Gestione squadra
- [x] Calendario
@@ -14,35 +16,45 @@ sta facendo adesso.
- [x] Badge
- [x] Badge social
- [x] Pagelle
- [x] Votazione MVP
- [x] Obiettivi di squadra
- [x] Turno palloni
- [x] Infortuni — conteggio eventi saltati, in forma minima
- [x] Notifiche Push (promemoria intelligenti)
## Versione 1.1
Le voci spuntate sono in produzione su `main`. I passaggi di attivazione ancora aperti (per
esempio il collegamento dei singoli account) stanno in [PROJECT_STATE.md](../PROJECT_STATE.md).
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
certificati resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del
profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni,
sollecito presenze)
- [x] Dashboard amministratore
- [x] Download CSV dati
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima 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] 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 — promemoria con «oggi/domani», turno palloni 12, 6 e 3 ore prima, sollecito
presenze a 24, 12 e 6 ore (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
## Versione 1.2
## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
## Versione 2.0
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica
- [x] Risultati campionato
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
## Idee future
- [ ] Gestione quote
-37
View File
@@ -1,37 +0,0 @@
# TODO
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
## In corso
- Documentazione tecnica del progetto.
- Autenticazione Google e dashboard amministratore: il codice è in produzione su `main` e il
login è l'unica via d'accesso. La migration M4, che chiude gli accessi `anon` alle
tabelle v1.0, è stata applicata in produzione (03/09/2026). Resta il collegamento dei
singoli account: ogni giocatore si aggancia al proprio profilo al primo login (DD-018), un
processo continuo — vale anche per chi viene aggiunto a stagione in corso da `/admin`.
Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
## Prossimo
- Niente di assegnato. Le voci ancora aperte in [ROADMAP.md](ROADMAP.md) sono «Calendario
ufficiale» (v2.0, i dati delle gare future arrivano già dal feed CSI) e la v1.2.
La gestione tesseramenti CSI della v1.1 è completa: raccolta dati, export CSV e tracciamento
di chi è già tesserato (numero e data di tessera, migration `m8_tesseramento_csi`, registrabili
da `/admin`).
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
la dashboard amministratore legge quei dati.
## Manutenzione ricorrente
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
## Backlog
- AI Allenamenti (roadmap v1.2).
- Backup automatici (roadmap, idee future).
-32
View File
@@ -1,32 +0,0 @@
# Visione del progetto
## Missione
CrAPP nasce con un obiettivo semplice:
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
## Principi del progetto
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
- Ridurre il lavoro amministrativo.
- Migliorare il coinvolgimento della squadra.
- Centralizzare tutte le informazioni in un'unica piattaforma.
- Automatizzare le attività ripetitive.
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
## Filosofia
CrAPP deve essere:
- Semplice
- Veloce
- Divertente
- Intuitiva
- Accessibile da smartphone
- Utilizzabile anche da persone poco esperte
## Obiettivo finale
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
+246
View File
@@ -0,0 +1,246 @@
# Worker email: installazione e gestione
Come si mette in funzione, si controlla e si diagnostica il worker (`mailer/`) che invia le notifiche
per email e le push delle notifiche che nascono nel database: promemoria a 24 e 3 ore, turno palloni a 12, 6 e 3 ore con revoca, solleciti presenze a 24, 12 e 6 ore (DD-040). Cosa fa il canale email sta in
[modules/notifiche-email.md](modules/notifiche-email.md), la push dei promemoria in
[modules/notifiche.md](modules/notifiche.md#push-dei-promemoria-m24); perché è fatto così in
[DD-036](DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa)
e [DD-038](DESIGN_DECISIONS.md#dd-038--ogni-notifica-ha-tre-canali-la-push-dei-promemoria-parte-dal-worker).
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`.
@@ -1,5 +1,5 @@
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
-- `profili-giocatore` creato dalla migration M3.
+211
View File
@@ -0,0 +1,211 @@
# Modulo — Ruolo Allenatore
**Stato:** implementato
**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)
**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore`
**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`,
`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`,
`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx`
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`,
`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`,
`test/integration/permessi-route.test.ts`
## Obiettivo
Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce
gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare
in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non
riceve voti e non ha statistiche, badge o stagione.
## Registrazione
Stesso flusso dei giocatori (DD-018), senza passaggi in più:
1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e
inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono.
2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è
automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori.
3. Entra in Home.
Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può
auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo).
## Profilo
Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro):
| Tab | Giocatore | Allenatore |
| ------------ | --------- | ---------------------------------------------- |
| Stagione | sì | **no** |
| Badge | sì | **no** |
| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale |
| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) |
Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti.
### Dati modificabili dall'allenatore
| Campo | Dove sta oggi | Nota |
| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- |
| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot |
| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin |
| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario |
| Luogo di nascita | `profili_giocatore.luogo_nascita` | |
| Telefono | `profili_giocatore.telefono` | |
| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori |
**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto
tessera, tesseramento CSI.
### Completamento profilo
Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25%
ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce
quando quei campi sono compilati.
## Home
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
vedono solo gli admin e il giocatore interessato (DD-035).
## Squadra
L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove
per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto
**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né
statistiche, e non compare nella tab Stats né nelle classifiche.
Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge**
non compaiono.
| Tab | Giocatore | Allenatore |
| --------- | --------- | ---------- |
| Rosa | sì | sì |
| Stats | sì | sì |
| Obiettivi | sì | **no** |
| Badge | sì | **no** |
## Badge e cacche nascosti
L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in
nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
Presenze, media voto, MVP e palloni restano visibili.
## Gestione eventi
L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina
eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database
la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a
cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche
a lui (`usePuoGestireEventi()`).
Può anche **sollecitare le presenze** di un evento, come l'admin: la route
`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il
sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. Con DD-040 si aggiungono solleciti automatici a 24, 12 e 6 ore prima dell'evento, ma il pulsante resta.
**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI,
notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle
risposte presenze altrui, gestione dei ruoli.
## Notifiche
Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i canali di tutti, sta in
[notifiche.md](notifiche.md#catalogo-delle-notifiche)):
| Notifica | L'allenatore la riceve? | Note |
| ------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria a 24 e 3 ore prima di un evento | **no** | Dal 29/09/2026 (m25, DD-039) `giocatori_destinatari_evento()` include solo i giocatori attivi: l'allenatore non può rispondere alle presenze né essere convocato, quindi il promemoria non gli chiederebbe nulla che possa fare (per esempio una cena di squadra). Prima gli arrivava sempre, convocato o no (m21). |
| Messaggio dello staff | **sì** | In-app ed email vanno a tutta la rosa attiva, allenatori compresi; la push ai suoi dispositivi iscritti, o solo a lui se il messaggio è per lui. |
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
| Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
spento l'interruttore «Email»; la push solo sui dispositivi su cui ha attivato «Notifiche» dal
proprio Profilo (l'interruttore è identico a quello dei giocatori). Il centro notifiche in-app
funziona come per i giocatori, con i soli messaggi dello staff.
## Esclusione dalla rosa
L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di
gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro
su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario
(`useAnagraficaRosa({ conAllenatori: true })`) e i destinatari dei messaggi admin, che lo
includono.
| Livello | Dove si esclude l'allenatore |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial`, `useIo()` e `useGiocatoreCorrente()` (quindi le notifiche smart) |
| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni |
| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` |
| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore |
| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) |
Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un
giocatore).
## Permessi (riepilogo)
| Azione | Giocatore | Allenatore | Admin |
| ------------------------------------------------------------------- | ---------- | ------------------ | ----------------------------------- |
| Modificare nome e cognome | no | sì, i propri | tutti |
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
| Documento, certificato, 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ì |
| Ricevere i promemoria automatici degli eventi (in-app, email, push) | sì | no (DD-039) | secondo il proprio slot |
| Ricevere i messaggi dello staff | sì | sì | secondo il proprio slot |
| Ricevere sollecito presenze, turno palloni, sondaggio cacche | sì | no | solo con uno slot giocatore in rosa |
| Ricevere le notifiche smart (badge, serie, obiettivi) | sì | no | solo con uno slot giocatore in rosa |
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
| Tab Stagione e Badge del profilo | sì | no | sì |
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
| Badge e classifiche/dati sulle cacche | sì | no | sì |
| Dashboard `/admin` | no | no | sì |
Le notifiche sono legate allo **slot** (`giocatori_squadra`), non al ruolo di admin: chi è admin le riceve
secondo il proprio slot, come giocatore o come allenatore, e non ne riceve se non ha uno slot.
## Implementazione
- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non
lascia usare un valore di enum nella transazione che lo crea.
- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli
allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger
`enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot
del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger
`sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al
collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o
allenatore; presenze, cacche e voti chiusi all'allenatore.
- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I
permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede
dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato.
- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non
chiede numero e ruolo. Il tipo si sceglie solo alla creazione.
## Scelte confermate
- Non riceve i promemoria degli eventi (DD-039; fino alla m24 li riceveva sempre, DD-034). Riceve i
messaggi dello staff.
- Sollecita le presenze: sì. Promemoria palloni: no.
- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio.
- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al
posto del ruolo.
- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti
gli autenticati.
+403 -8
View File
@@ -1,6 +1,6 @@
# Modulo — Badge
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
**Stato:** implementato, coerente con DD-007 e DD-008
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
@@ -26,16 +26,45 @@ badge assegnati per voto dai compagni.
## Implementazione
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
criterio delle presenze).
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
(modificabile), con **auto-voto escluso sia in UI sia in logica** (`VotoSocial.tsx`). A
(modificabile), con **auto-voto escluso in interfaccia** (`VotoSocial.tsx`) e rifiutato dal
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
`votante_id`/`votato_id` sono entrambi visibili.
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
@@ -45,6 +74,61 @@ badge assegnati per voto dai compagni.
---
## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia.
### Badge normali (gradi bronzo/argento/oro)
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O |
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati)
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
### Badge social (votati dai compagni, 5 categorie per partita)
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
voti ricevuti).
| id | nome | cosa premia |
| ------------ | -------------------------- | ------------------------------------------------ |
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
| `meme` | Meme della partita | la scena più memorabile della partita |
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
---
## Regole rispettate
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
@@ -54,6 +138,285 @@ badge assegnati per voto dai compagni.
---
## 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.
### 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`) |
---
## Problemi noti da sistemare
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
CHECK o FK verso i 5 id di `categorieSocial`
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
---
## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
@@ -63,11 +426,39 @@ badge assegnati per voto dai compagni.
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente.
- RLS permissiva su `badge_social_voti` (stesso schema di `mvp_voti`): nessun controllo
server-side che `votante_id` coincida con l'utente autenticato.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo.
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
---
## Evoluzioni possibili
@@ -75,3 +466,7 @@ badge assegnati per voto dai compagni.
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
+80
View File
@@ -0,0 +1,80 @@
# Modulo — Calendario ed Eventi
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
---
## Obiettivo
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
## Due schermate, due pubblici
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
`eventi_app`.
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
## Lettura lato server
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
---
## Limiti noti
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
(`m14_pulizia_dati_evento_cancellato`,
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
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`).
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
+286 -37
View File
@@ -1,7 +1,8 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Route interessata:** `/classifica`
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
---
@@ -21,26 +22,134 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
API key, ma **non offrono alcuna garanzia di stabilità**.
| Endpoint | Formato | Uso |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `team-roster.php`, `team-results.php`.
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
| Cosa | Valore |
| ------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` | `767` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| Cosa | Valore |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
(URL del referto ufficiale, `match_details.php?id=…`).
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
`team-scorers.php`.
### Dettaglio di una singola gara (formazioni e scontri diretti)
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
`assets/js/match.js`:
| Endpoint | Formato | Uso |
| -------------------------------------------- | ------- | ------------------------------------------------------------------- |
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
riepilogo aggregato — vedi sotto).
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
interpretata come un flag booleano.
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
(referto non ancora compilato o formato cambiato).
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
identificabile).
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
---
@@ -49,29 +158,84 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
```
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓
/classifica → src/routes/classifica.tsx
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
sotto; tab "Storico partite": ogni squadra col proprio logo,
chevron di dettaglio sulle gare cliccabili, gare divise
per stagione; stagione anche nel sottotitolo dell'header)
CSI (portale, 3 endpoint)
↓ fetch server-side on-demand, cache per-partita 6 ore
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
↓
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
↓
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
```
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
del girone.
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
per riga, vedi "Regole rispettate" sotto).
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
(formazioni + scontri diretti, monta `useCsiPartita()`).
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
(`PartitaCsi.id`), non un evento CrAPP.
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
prima, quando `csiMatch` esiste per quella data.
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
gara giocata.
### Regole rispettate
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
---
@@ -83,18 +247,103 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
a mano in `csi-core.ts`.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
pagina campionato molto meno. Il fix, quando succede, è isolato a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
errore SQL del loro backend in chiaro al posto del JSON
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
del CSI", non come cronaca esatta di chi ha giocato quanto.
---
## Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
`project-sheets.php?project_id=848` invece di essere una costante.
+8 -1
View File
@@ -27,6 +27,12 @@ volte compare lo stato `infortunato` nella mappa presenze già in cache (nessuna
aggiuntiva). Lo stesso meccanismo, con `contaRitardi()`, conta i ritardi. Il risultato
alimenta il campo `infortuni` del `Giocatore` in `useRosa()`.
Un evento con data futura o odierna non viene contato, anche se la risposta è già registrata
(l'UI permette di segnarsi infortunato o in ritardo su un evento non ancora passato): il
conteggio filtra su `data < oggi`, come già fa `eventiContanoPresenze()` in
[presenze.md](presenze.md), e cresce da solo con l'avanzare della data reale senza bisogno di
altro codice.
Visibile in UI solo indirettamente, tramite il [badge](badge.md) segreto "Cliente VIP
dell'Infermeria" (sbloccato con almeno 3 infortuni): non esiste uno StatTile dedicato nel
profilo che mostri il numero di infortuni come statistica di superficie.
@@ -38,7 +44,8 @@ profilo che mostri il numero di infortuni come statistica di superficie.
- Nessuna durata o periodo tracciato: è solo un conteggio di eventi con quello stato, non un
inizio/fine infortunio.
- Il conteggio dipende dal fatto che qualcuno imposti correttamente lo stato "infortunato"
invece di "assente": nessuna validazione o promemoria lo garantisce.
invece di "assente": nessuna validazione o promemoria lo garantisce. Sulle card degli
eventi extra-campo (`tipo: "evento"`) l'opzione non è proposta in UI.
- Poco visibile per valori bassi (1-2), perché emerge solo tramite un badge a soglia 3.
- `conInfortuni()`, una funzione di merge alternativa nello stesso file, non risulta usata da
nessuna parte del codice attuale — probabile residuo non collegato.
+30 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Votazione MVP
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
---
@@ -17,16 +17,29 @@ calcolato a runtime.
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, sovrascrivibile.
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
restano nel database ma non vengono più letti da nessuna schermata).
---
## Implementazione
- Il pannello compare in `partita.$id.tsx` solo se esiste un risultato (Scout Live salvato)
per la partita, altrimenti mostra "la partita non è ancora stata disputata".
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
quando aprirà. Nessun legame con il risultato caricato.
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
@@ -35,18 +48,23 @@ partita, sovrascrivibile.
## Limiti noti
- **Nessun controllo che impedisca di votare se stessi** — a differenza dei
[Badge social](badge.md), che escludono esplicitamente l'auto-voto. È una lacuna, non un
limite di design dichiarato altrove.
- Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
- RLS permissiva: `votante_id`/`votato_id` sono testo libero inviato dal client (l'id
giocatore proviene da `localStorage`, non da un claim di sessione verificato server-side);
nessun trigger lega il voto all'utente autenticato.
- In caso di parità, nessun MVP viene assegnato per quella partita.
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
partita.
---
## Evoluzioni possibili
- Impedire l'auto-voto come già avviene nei Badge social.
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
+244
View File
@@ -0,0 +1,244 @@
# 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](../DESIGN_DECISIONS.md#dd-036--notifiche-email-via-gmail-da-un-worker-docker-sullhost-di-casa).
## 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, promemoria a 24 ore e a
3 ore, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
**non** vengono inviate: il trigger non ha backfill.
Il [catalogo delle notifiche](notifiche.md#catalogo-delle-notifiche) elenca cosa fa partire ciascuna
e i suoi canali: tutte le notifiche generate dal server passano da `notifiche_utente` e quindi
arrivano anche per email (DD-038).
**Chi le riceve:** chi ha la riga in `notifiche_utente`, con la stessa logica delle notifiche
in-app. Gli admin non hanno un percorso a parte: ricevono ciò che riceverebbero secondo il proprio
slot. L'allenatore riceve solo i messaggi dello staff, non i promemoria degli eventi (DD-039), né il
sollecito, il turno palloni e il sondaggio (DD-034, DD-038).
Il certificato in scadenza **non genera mail**: non è una notifica ma una card calcolata in Home
(DD-035). Se un giorno lo si volesse anche per email sarebbe una sorgente nuova di
`notifiche_utente`, da decidere a parte.
**Indirizzo:** `giocatori_squadra.email`, la stessa colonna che collega account e giocatore
(DD-018). Uno slot senza `auth_user_id` (giocatore non ancora registrato), senza email o il cui
account ha spento le email non riceve nulla: la riga in coda passa a `saltata`, senza errore.
## Mittente
Il worker invia con un **account Gmail dedicato**, tramite `smtp.gmail.com:587`, autenticandosi con
una **password per app**. Come crearla e i problemi noti stanno in
[WORKER_EMAIL.md](../WORKER_EMAIL.md#account-gmail-e-password-per-app).
Perché Gmail e non un relay come Brevo o Resend: i destinatari sono già su Gmail, quindi la mail
parte e arriva dentro la stessa infrastruttura, con SPF e DKIM già a posto e senza comprare né
configurare un dominio. Gmail riscrive l'intestazione `From` con l'indirizzo dell'account: il nome
visibile è «CrAPP», l'indirizzo no. Il tetto è di circa 500 destinatari al giorno per un account
gratuito (valore da verificare); il worker si ferma a `MAIL_LIMITE_GIORNO`. **Non si invia mai
direttamente dal proprio IP di casa**: gli IP residenziali sono nelle blocklist e senza reverse DNS
le mail finiscono in spam o vengono rifiutate.
## Dati
Lo schema completo, con permessi e note, sta in [DATABASE.md](../DATABASE.md); qui il quadro
funzionale.
**`notifiche_email_coda`** — una riga per notifica da mandare (`notifica_id` è chiave primaria e
chiave esterna con `ON DELETE CASCADE`: cancellare la notifica, per esempio con lo swipe, toglie
anche la mail non ancora partita).
| Colonna | Note |
| -------------------- | ----------------------------------------------------------------------- |
| `stato` | `in_coda` · `in_invio` · `inviata` · `fallita` · `saltata` |
| `tentativi` | invii falliti finora; non cresce per i rinvii «differiti» (vedi sotto) |
| `prossimo_tentativo` | il worker prende la riga solo se `<= now()` (backoff) |
| `errore` | solo i codici SMTP dell'ultimo errore, mai il testo delle mail |
| `inviata_il` | valorizzato a invio riuscito; alimenta il conteggio delle ultime 24 ore |
| `aggiornata_il` | serve a riconoscere le righe rimaste `in_invio` per un worker morto |
È una tabella a parte e non colonne su `notifiche_utente` perché quella ha una policy `UPDATE` per
il giocatore su tutta la riga: un client potrebbe riportare a `in_coda` una mail già partita. La
coda ha la RLS attiva **senza alcuna policy** e i permessi tolti a `anon` e `authenticated`: la
legge e la scrive solo la service role, cioè il worker.
**`preferenze_utente`** — una riga per **account** (`auth_user_id`, non lo slot giocatore), per ora
con la sola colonna `email_notifiche` (default `true`). **Nessuna riga significa email attive**: la
riga nasce solo quando il giocatore tocca l'interruttore, con un upsert; ognuno legge e scrive solo
la propria, per RLS. Non sta su `giocatori_squadra`, che ha una riga per slot e il giocatore può
modificare solo in parte (DD-016).
**Funzioni RPC** — tutte `SECURITY DEFINER` e con `EXECUTE` solo alla service role:
| Funzione | Cosa fa |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `prendi_notifiche_email(p_max)` | Prende fino a `p_max` righe `in_coda` scadute con `FOR UPDATE SKIP LOCKED`, le marca `in_invio` e restituisce indirizzo, oggetto, testo, evento, tipo. |
| `esito_notifica_email(id, esito, errore, ora)` | Registra `inviata`, `riprova`, `differita` o `fallita`; agisce solo su righe `in_invio`, così un esito in ritardo non ne sovrascrive uno già deciso. |
| `email_inviate_ultime_24h()` | Conta le mail inviate nelle ultime 24 ore, per restare sotto il tetto. |
## Flusso
1. Una sorgente M17 inserisce (o aggiorna con `upsert`) una riga in `notifiche_utente`.
2. Il trigger `notifiche_utente_accoda_email` (`AFTER INSERT OR UPDATE OF creato_il`) la mette in
`notifiche_email_coda`. L'`upsert` di turno palloni, sollecito presenze e sondaggio riscrive `creato_il`: è
un rinvio voluto e rimette in coda anche la mail. Segnare come letta e l'`ON CONFLICT DO NOTHING`
dei cron non toccano quella colonna e non accodano nulla.
3. Ogni `POLL_SECONDI` (30) il worker conta le mail delle ultime 24 ore, ne chiede al massimo quante
ne restano sotto il tetto (al più `MAIL_LOTTO`) a `prendi_notifiche_email` e le invia una a una.
Prima di prendere il lotto la funzione SQL fa due pulizie: riporta a `in_coda` le righe ferme
`in_invio` da più di 10 minuti (worker morto a metà) e segna `saltata` quelle senza destinatario
o con le email spente.
4. Ogni invio registra l'esito con `esito_notifica_email`.
La preferenza è valutata **al momento dell'invio**, non dell'accodamento: spegnere l'interruttore
ferma anche le mail già in coda.
## Affidabilità
Il worker classifica l'errore SMTP (`classificaErroreSmtp`):
| Tipo | Esempi | Cosa succede |
| ---------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `temporaneo` | 4xx, timeout, connessione caduta | Riprova con backoff 1 · 5 · 30 · 120 min; al quinto invio fallito passa a `fallita`. |
| `permanente` | 5xx sul destinatario o sul messaggio, indirizzo non valido | `fallita` subito, senza altri tentativi. |
| `limite` | `550 5.4.5 Daily user sending quota exceeded` | Tutto il lotto torna in coda tra un'ora **senza consumare tentativi**; il ciclo si ferma. |
| `configurazione` | credenziali SMTP rifiutate (535, 534, 530, `EAUTH`) | Come sopra ma tra 15 minuti, e il container **non** risulta più sano (vedi sotto). |
Altre situazioni:
| Situazione | Comportamento |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Worker fermo o host spento | Le mail restano `in_coda` e partono alla ripartenza. |
| Crash tra l'invio e la registrazione | Consegna _almeno una volta_: la riga resta `in_invio`, dopo 10 minuti torna in coda e la mail può arrivare due volte. Mai una perdita. |
| Errore nella registrazione dell'esito | Non si tratta come errore SMTP (non parte un secondo invio): il ciclo si interrompe e la riga viene recuperata come sopra. |
| Tetto giornaliero raggiunto | Finestra mobile di 24 ore su `inviata_il`, non «fino a mezzanotte»: il worker riprende da solo quando le mail più vecchie escono dalla finestra. |
`MAIL_LIMITE_GIORNO` vale 400 di default, sotto i circa 500 di Gmail, per lasciare margine ai
messaggi che l'account dedicato potesse mandare per altre vie.
**Salute del container.** `mailer.mjs` scrive un timestamp in `/tmp/mailer-ok` a ogni ciclo
riuscito; `healthcheck.mjs` (eseguito da Docker ogni minuto, con 30 secondi di tolleranza
all'avvio) segnala non sano il container se l'ultimo è più vecchio di 5 minuti. Un ciclo fermato
dalle credenziali SMTP rifiutate **non** aggiorna il file, così dopo qualche minuto `docker ps`
mostra `unhealthy` invece di far credere che tutto funzioni. Un ciclo fermato dal solo tetto
giornaliero è normale e resta sano.
## Contenuto delle mail
Sobrio, in testo semplice più una versione HTML minima leggibile da smartphone: oggetto = titolo
della notifica (su una riga sola, massimo 200 caratteri), corpo = corpo della notifica, un pulsante
«Apri CrAPP» verso `<APP_URL>/evento/<id>` quando la notifica riguarda un evento e verso la home
altrimenti. In fondo una riga che spiega come disattivare le email (Profilo → Opzioni → Email).
Nessuna immagine remota, nessun tracciamento delle aperture. Il testo scritto dall'admin viene
sempre escapato nell'HTML. Il `Message-ID` è derivato dall'id della notifica.
## Interfaccia
Un solo cambio: in Profilo → Opzioni, sotto «Notifiche», un interruttore **«Email»** (icona busta)
che scrive `preferenze_utente` per l'account, con lo stesso stile dell'interruttore «Notifiche»
(`useEmailNotifiche()` in `src/lib/preferenze-utente.ts`). Vale per tutti gli slot dell'account.
Nessuna schermata nuova.
## Privacy e sicurezza
- Le email personali dei giocatori non lasciano Supabase se non verso Gmail. Il worker legge solo
l'indirizzo dei destinatari del lotto che sta spedendo, non l'intera rosa.
- La service role sta solo sull'host del worker, in un `.env` con permessi `600`; non passa da
Vercel.
- La password per app si può revocare da Google senza toccare l'account.
- I log del worker riportano solo id notifica, tipo ed esito: **mai indirizzi né testi**, e degli
errori SMTP solo i codici. Il log del container è limitato a 3 file da 5 MB.
- Il canale è **opt-out**: acceso di default, spegnibile dal giocatore. Un interruttore unico, come
per la push (nessuna preferenza per tipo).
## Test
- **Unit** — [test/unit/mailer-core.test.ts](../../test/unit/mailer-core.test.ts): configurazione,
classificazione degli errori SMTP, backoff, tetto giornaliero, composizione della mail (escape,
oggetto, link, `Message-ID`), salute, e il ciclo di invio con dipendenze finte (esiti, fermata su
tetto e credenziali, registrazione dell'esito che fallisce, log senza dati personali).
[test/unit/preferenze-utente.test.ts](../../test/unit/preferenze-utente.test.ts): il default
«acceso» senza riga.
- **Integrazione** — [test/integration/notifiche-email.test.ts](../../test/integration/notifiche-email.test.ts),
sul database locale: trigger su ogni tipo, rinvio da `upsert`, cron che non accoda due volte,
cascata alla cancellazione, `prendi_notifiche_email` (destinatario, una sola volta, chiamate
concorrenti senza doppioni, slot senza account o senza email, righe orfane recuperate),
preferenza (default, spenta, riaccesa, RLS sulle righe altrui), tutti gli esiti, e i permessi (un
giocatore o un admin non vedono la coda né chiamano le funzioni del worker).
- Nessun test spedisce mail vere: il trasporto SMTP è sostituito da un finto nei test unitari.
L'invio reale a Gmail è stato verificato a mano il 29/09/2026 (vedi Limiti noti).
## Limiti noti
- Il canale email dipende da un host acceso: non c'è alta disponibilità.
- Un 250 da Gmail vuol dire «accettata», non «letta»: non c'è conferma di lettura e non se ne vuole
una.
- **Le prime mail possono finire in spam.** Verificato il 29/09/2026: la mail di prova, da un
account nuovo e senza reputazione, è arrivata ma nella cartella spam. Non dipende dalla
configurazione (Gmail accetta e consegna) ma dalla reputazione del mittente. Si migliora
segnando i messaggi «Non è spam» e aggiungendo il mittente ai contatti; chi riceve la prima mail
va avvisato. Se resta un problema serve un mittente con dominio proprio (vedi Evoluzioni).
- Un account collegato a più slot giocatore (`auth_user_id` ripetuto) riceve una mail per slot, dato
che `notifiche_utente` ha una riga per `giocatore_id`.
- Cambiare l'indirizzo in `giocatori_squadra.email` cambia dove vanno le mail future; le righe già
`inviata` non si toccano.
- `notifiche_email_coda` non ha pulizia automatica, come `notifiche_utente` (DD-030): le righe
spariscono quando la notifica viene eliminata.
- L'interruttore «Email» compare solo con il codice che lo contiene: finché una versione più vecchia
dell'app è in produzione, le mail partono ma non si possono spegnere dall'app.
## Evoluzioni possibili
- Un relay con dominio proprio (Brevo, Resend) se serve un mittente della squadra, se le mail
restano in spam o si supera il tetto di Gmail.
- Riepilogo settimanale unico al posto di una mail per notifica.
- Preferenze per tipo di notifica, nella stessa `preferenze_utente`, insieme a quelle della push.
- Rendere il certificato in scadenza una notifica vera (sorgente nuova di `notifiche_utente`).
+347 -27
View File
@@ -1,10 +1,11 @@
# Modulo — Notifiche
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`,
`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-messaggio.ts`, `src/routes/api/public/push-subscribe.ts`,
`public/push-sw.js`
**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un
centro notifiche in-app indipendente, con un pallino sull'avatar del profilo
**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`,
`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`,
`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`,
`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`)
---
@@ -13,17 +14,161 @@
Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (turno palloni, sollecito presenze).
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
---
## Catalogo delle notifiche
Cosa fa partire una notifica, a chi arriva e su quali canali. Sono i canali indipendenti descritti
più sotto: **push** (solo ai dispositivi che hanno attivato «Notifiche»), **centro notifiche
in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
quelle del turno palloni**, che restano solo push e in-app: DD-040).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
| 1 | **Promemoria a 24 ore** — «Promemoria evento di _domani_ (o _oggi_): _titolo_», con data, ora e luogo | Job `pg_cron` `promemoria-eventi-24h`, ogni ora, per gli eventi che iniziano entro le prossime 24 ore. Tipo `evento_promemoria_24h`. La push la manda il worker (M24). | I convocati dell'evento, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori (DD-039). | sì (worker) | sì | sì |
| 2 | **Promemoria a 3 ore** — stesso testo | Job `pg_cron` `promemoria-eventi-3h`, ogni 15 minuti, per gli eventi che iniziano entro le prossime 3 ore. Tipo `evento_promemoria_3h`. La push la manda il worker (M24). | Come il promemoria a 24 ore. | sì (worker) | sì | sì |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **12, 6 e 3 ore prima** dell'evento (DD-040): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipi `turno_palloni_12h`, `_6h` e `_3h`, 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**, in tre tempi (DD-040): **24, 12 e 6 ore** prima dell'evento, dallo stesso job; tipi `sollecita_presenze_24h`, `_12h`, `_6h`, push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
automatiche (1, 2, 4 e 5) 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
> verificate in locale; la migration va ancora applicata in produzione. Finché non lo è, l'app si comporta
> come in 1.2.0: promemoria con il testo breve, turno palloni e sollecito presenze solo da pulsante.
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 | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
Le righe 1-2, 4a-4c e 5 nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
`genera_solleciti_presenze_fascia`); i pulsanti usano gli stessi testi da `palloni-core.ts`
(`avvisiPalloniEvento`) e `presenze.ts` (`testoSollecito`), e `notifiche-automatiche.test.ts` verifica che siano
identici. In tutte la riga «Luogo» manca se l'evento non ha un luogo. Fino all'applicazione della `m26` in produzione valgono i testi di 1.2.0: promemoria
«Promemoria: _titolo_» con corpo «_data_ alle _ora_», turno palloni «Tocca a te prendere i palloni» / «Porta
i palloni», sollecito «Manca la tua risposta». Le righe 3 e 6 non cambiano:
`notifica-personalizzata.ts` e `cacche.ts` (`avvisoSondaggio`).
**Notifiche smart (riga 7).** Solo locali: la notifica di sistema ha il titolo preceduto
dall'emoji. Non entrano nel centro notifiche e non generano email.
| Situazione | Titolo | Corpo |
| ---------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| Badge sbloccato (🏅) | `<badge> <grado>` | `<valore> <unità>: badge sbloccato, complimenti!` |
| Badge segreto | `Badge segreto: <nome>` | Testo del badge (`notificaPush`, poi `celebrazione`), altrimenti «Hai scoperto un badge nascosto!» |
| A un passo da un badge (🔥) | `Sei a un passo` | `<N> <unità> e sblocchi <badge>.` — scatta con al massimo 2 unità mancanti |
| Serie a un traguardo esatto (⚡) | `Serie di <N> <tipo>` | `Continuità da veterano: non fermarti ora.` |
| Obiettivo di squadra tra 90% e 99% | `<obiettivo>: <pct>%` | Microtesto di `microcopyObiettivo()`, dipende dall'obiettivo |
| Badge social vinto | `<categoria> x<N>` | `I tuoi compagni hanno votato per te.` |
### Cosa non genera nessuna notifica
- Creare, spostare, modificare o cancellare un evento: contano solo i promemoria prima
dell'inizio (DD-030).
- L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
### Comportamenti da conoscere
Sono l'effetto attuale della logica, non scelte documentate altrove:
- **I 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.
- **I solleciti cadono in finestre che non si sovrappongono** (DD-040): 24 ore tra 24 e 12 ore
prima dell'inizio, 12 ore tra 12 e 6, 6 ore sotto le 6. Un evento creato a meno di 12 ore
dall'inizio riceve solo quello della sua finestra. Chi risponde presente, assente o in ritardo
smette di riceverli; chi resta su «forse» li riceve tutti e tre.
- **Per ogni evento un giocatore riceve al massimo cinque mail automatiche** (DD-040): due
promemoria e tre solleciti, più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
### Test che verificano il catalogo
| Cosa | Test |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Promemoria 24 e 3 ore: destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts` |
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 12·6·3 ore e revoca, solleciti a 24·12·6 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`) | `integration/notifiche-automatiche.test.ts` (nuovo), `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
catena `useIo()` → `useRosa()` → `useNotificheSmart()` è fatta di hook e si conosce dalla lettura del
codice; l'unico pezzo puro, `inRosa`, è coperto.
---
## Dati
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`), `promemoria_push` (coda
"consuma e cancella" del testo da mostrare — nonostante il nome, **non** è uno storico
persistente: la riga viene eliminata non appena letta dal service worker).
`push_subscriptions` (un dispositivo per riga, chiave `endpoint`, con le chiavi `p256dh` e
`auth` con cui si cifra il payload per quel dispositivo). La tabella `promemoria_push` non è
più usata da nessuno: serviva da coda del testo quando la push partiva vuota (DD-026).
---
@@ -40,24 +185,70 @@ smart in app, che usano lo stesso service worker.
4. `POST /api/public/push-subscribe` registra endpoint e chiavi in `push_subscriptions`
(upsert).
All'avvio e quando l'app torna visibile viene richiesto l'aggiornamento della registrazione
push esistente con `ServiceWorkerRegistration.update()`. Non si chiede un nuovo permesso,
non si ricrea la sottoscrizione e non si cambia l'endpoint: anche chi ha già attivato le
notifiche deve ricevere le correzioni del worker senza spegnere e riaccendere l'interruttore.
Gli aggiornamenti contemporanei sono accorpati; un errore di rete non blocca l'app e si
riprova al ritorno in primo piano. Il worker attende `skipWaiting()` durante l'installazione.
Il browser controlla anche autonomamente gli aggiornamenti: questa richiesta esplicita
copre in particolare le sessioni lunghe della webapp (vedi il
[ciclo di vita del service worker](https://web.dev/articles/service-worker-lifecycle)).
---
## Ruolo delle tre route pubbliche
- **`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: mette in coda su
`promemoria_push` l'avviso di apertura del sondaggio pre-partita per **tutti** i dispositivi
iscritti e manda la push (vedi [Scout Live](scout-live.md)).
- **`push-messaggio`** — non invia nulla: il service worker la interroga **al momento della
ricezione** di una push (che arriva sempre "vuota", senza testo, per compatibilità) per
sapere quale messaggio mostrare. Priorità: un messaggio in coda su `promemoria_push`
(scritto da `sollecita-presenze`, vedi [Presenze](presenze.md)), altrimenti il messaggio
calcolato al volo sul turno palloni (vedi [Palloni](palloni.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) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da
`sollecita-presenze.ts` e `promemoria-palloni.ts`.
(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` .
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
rete. È il punto decisivo per la consegna ad app chiusa — il browser sveglia il worker per
pochi secondi, e una fetch per recuperare il testo lo faceva morire prima di
`showNotification` (DD-026).
La POST porta `Urgency: high`. Con l'urgenza predefinita ("normal") un telefono in risparmio
energetico accumula i messaggi fino al risveglio: la notifica arriva solo quando il
dispositivo è già attivo — cioè, nella pratica, solo con l'app aperta.
### Chi può farle partire (DD-024, DD-025)
Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella
route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo
è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna
variabile d'ambiente.
| Route | Controllo | Chi la chiama |
| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
Con DD-040 `promemoria-palloni` e `sollecita-presenze` restano, accanto ai job automatici (la seconda
anche per l'allenatore, `richiediGestoreEventi()`).
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
l'ha ancora attivata.
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
---
@@ -69,29 +260,158 @@ traguardo esatto, obiettivo di squadra tra il 90 e il 100%, badge social vinto.
ha un id deterministico; quelli già mostrati sono salvati in `localStorage` per non
ripetersi — deduplica puramente locale al dispositivo, non sincronizzata.
**Chi le riceve.** Solo chi ha selezionato sul dispositivo uno slot **giocatore in rosa**:
`CelebrazioneBadge` (montato una volta in `__root.tsx`) passa a `useNotificheSmart()` il giocatore
letto da `useGiocatoreCorrente()`, cioè `useIo()`, che lo cerca in `useRosa()`. Quell'elenco esclude
gli allenatori (`inRosa`, DD-034), che non hanno statistiche: per loro `useIo()` è `null` e
`useNotificheSmart()` esce subito, senza calcolare nulla né mostrare la card celebrativa o la
notifica del sistema. Vedi [allenatore.md](allenatore.md#notifiche).
---
## Centro notifiche in-app (M17)
Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro
`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se
sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per
eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il
tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una
campanella separata accanto all'avatar. Indipendente dal canale push sopra:
non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in
`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la
stessa cosa delle notifiche smart (quelle restano locali, non salvate a database).
Cinque sorgenti scrivono in `notifiche_utente`, mai il client (con DD-040 le sorgenti 3 e 4 hanno in più un
job `pg_cron` come la 2, che scrive `turno_palloni_12h`/`_6h`/`_3h`/`_revocato` e `sollecita_presenze_24h`/
`_12h`/`_6h`; 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
minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati
(o tutta la rosa attiva se l'evento non ne specifica), deduplicata dal registro
`promemoria_eventi_generati` (M23, DD-037; il vincolo `UNIQUE (giocatore_id, evento_id, tipo)`
di `notifiche_utente` resta come seconda difesa) così un cron che gira più volte non manda
doppioni, nemmeno se il giocatore elimina la notifica. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta
l'avvicinarsi della data, non il momento in cui è stato messo in calendario.
3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina
evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato
da `avvisiPalloniEvento()`, in parallelo alla push.
4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una
riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push.
5. **Sondaggio pre-partita** — `apri-sondaggio.ts` (pulsante riservato agli admin sulla pagina
della partita) inserisce una riga di tipo `sondaggio_cacche` per ciascun giocatore attivo,
allenatori esclusi (`destinatariSondaggio()`), in parallelo alla push (M24, DD-038).
Turno palloni, sollecito presenze e sondaggio usano un `upsert` su `(giocatore_id, evento_id, tipo)`
invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la
notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false)
invece di duplicarsi o fallire per il vincolo `UNIQUE`.
Il corpo delle notifiche automatiche è a righe etichettate (Data, Ora, Luogo…): `RigaNotifica` lo mostra con
`whitespace-pre-line`, e un test di `unit/notifiche-utente.test.ts` verifica che la classe ci sia.
Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS
(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata
— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte
le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola"
separato.
**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle
sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga
(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la
× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la
ritrova per sempre nell'elenco, solo segnata come letta.
## Push dei promemoria (M24)
I promemoria a 24 e 3 ore nascono 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
è iscritto o no. Separare i canali richiederebbe schema e UI dedicati.
- `promemoria_push` è descritta altrove come "storico" ma nel codice è una coda che si
autocancella alla lettura: non conserva nulla.
- Nessuna verifica di autenticazione su `push-messaggio` (chiunque conosca un endpoint push
valido può leggerne il messaggio) né su `promemoria-palloni`.
- La tabella `promemoria_push` è rimasta nel database ma non la usa più nessuno (DD-026): va
eliminata con una migrazione alla prossima occasione.
- **Un 2xx dal server push non significa consegnato.** FCM accetta con 201 anche verso
registrazioni scadute e poi butta via il messaggio, senza il 404/410 che farebbe pulire
`push_subscriptions`. Il conteggio "inviate a N dispositivi" va letto come "accettate da N
server push", non come "arrivate a N telefoni".
- Compatibilità iOS/Safari non gestita esplicitamente nel codice (nessun branch dedicato):
serve l'installazione da schermata Home per funzionare, ma l'app non lo segnala
esplicitamente.
esplicitamente. È il primo sospetto quando una notifica non arriva ad app chiusa su iPhone.
- **Su Android non riceve la webapp: riceve il browser.** Il WebAPK è solo l'identità con
cui la notifica viene mostrata; la connessione con i server push la tiene Chrome, tramite
Google Play Services. Se Android non può avviare Chrome, il messaggio resta in coda e
compare tutto insieme al lancio successivo — il sintomo classico è «arriva solo quando
riapro l'app». Un 201 dal servizio push non lo distingue in alcun modo da una consegna
riuscita.
Verificato sul campo (settembre 2026, Motorola): con Chrome vivo in secondo piano la push
arriva ad app chiusa e schermo bloccato, WebAPK compreso — quindi server, cifratura,
service worker, permesso notifiche e canale erano già corretti. L'unica condizione che
fallisce è **Chrome non in esecuzione**. La cura sta in Impostazioni → App → **Chrome** →
Batteria → «Senza restrizioni», più Impostazioni → Batteria → «Batteria adattiva»
disattivata. Mettere «Senza restrizioni» solo su CrAPP non basta e depista.
**Come misurarlo invece di indovinare:** `chrome://gcm-internals` sul telefono, sezione
«Receive Message Log». Se la riga porta l'orario dell'invio, il messaggio era arrivato e
non è stato mostrato (permesso o canale); se porta l'orario in cui si è riaperta l'app,
non era stato consegnato (risveglio, quindi batteria). Attenzione: tenere quella scheda
aperta **tiene Chrome vivo**, quindi falsa la prova stretta — per quella, nessuna scheda
aperta e Chrome tolto dai recenti.
- Su Motorola verificare anche le restrizioni del **browser che ha installato CrAPP** e,
dove presente, Impostazioni → Batteria → Ottimizzazione standby app. Il produttore
documenta la limitazione dei processi in background
([guida Motorola](https://help.motorola.com/hc/3505/14/global/en-us/CG2007980805.html)).
È una possibile causa del sintomo, non una diagnosi verificata sul dispositivo: il
codice web non può rimuovere questi vincoli. La verifica richiede un invio da un altro
dispositivo mentre CrAPP è chiusa e lo schermo del Motorola è bloccato. Il pulsante di
prova invia subito, quindi da solo non dimostra la ricezione in background.
- Le notifiche smart dipendono da un service worker già registrato: se il giocatore non ha
mai attivato le push, `notificaSistema()` non ha un `reg` a cui appoggiarsi e la notifica
locale non viene mai mostrata, anche con permesso concesso.
- Payload push sempre vuoto: ogni notifica richiede una fetch aggiuntiva (`push-messaggio`)
per ottenere il testo, quindi serve rete disponibile anche solo per mostrare il messaggio.
- Il payload cifrato non può superare i ~4 KB: i testi attuali stanno larghi, ma un messaggio
molto lungo verrebbe rifiutato dal servizio push.
---
## Evoluzioni possibili
- Preferenze per canale (palloni, solleciti, smart), se servono davvero alla squadra.
- Aggiungere autenticazione alle route pubbliche coinvolte.
- Eliminare `promemoria_push` con una migrazione.
- Gestire esplicitamente il caso iOS (messaggio se l'app non è installata da Home).
+142 -29
View File
@@ -1,6 +1,7 @@
# Modulo — Obiettivi di squadra
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
---
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
## Dati
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
serie).
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
ordina mettendo i completati in coda e gli altri per progresso decrescente.
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
cancellazione: risolto a livello database con un trigger (migration
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
corrente dinamico (vedi sotto).
---
## Obiettivi definiti
| Obiettivo | Calcolo | Target | Fonte |
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`).
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
---
## Limiti noti
## Obiettivi mensili — mese dinamico
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
squadra risponde alle convocazioni, non solo la presenza.
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
sono "congelati" su un mese già passato.
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
ogni stagione.
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
seguono di conseguenza.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
---
## Evoluzioni possibili
## Continuità di squadra — il target 12 è il minimo per un 6vs6
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
---
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
proprio target, nemmeno con più vittorie di quante ne servano.
### Il limite: il parsing del JSON può rompersi in silenzio
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
comunque `200` con `partite: []`.
### Come è mitigato oggi
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
anche loro).
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
### Come fixarlo, se succede
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
restano verdi contro un formato che non esiste più.
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
[Collegamento CSI](collegamento-csi.md).
---
## Copertura test
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ---------- | ----------------------------- | -------------------------------------------------------- |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
(evento dentro/fuori mese), scadenza dinamica.
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
arrotondata e conteggio.
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
verifica cap e target su dati reali.
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
+25 -12
View File
@@ -1,6 +1,6 @@
# Modulo — Pagelle
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
---
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
`squadra.tsx`.
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
---
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
voto in UI.
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
`evento_permette_voto()`, `pagelle_voti`).
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
database, non solo in UI (stessa RLS di sopra).
---
## Limiti noti
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
- Nessun controllo a livello database che il votante sia realmente un convocato della
partita: solo filtro applicativo.
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
coincide con quel voto.
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
considerarla — profilo e squadra no.
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
---
## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+50 -24
View File
@@ -1,6 +1,6 @@
# Modulo — Palloni
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
`src/routes/api/public/promemoria-palloni.ts`
@@ -10,8 +10,9 @@
## Obiettivo
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
giorno stesso.
partite, con proposta automatica, possibilità di modifica manuale e avvisi push, nel centro
notifiche e senza email a 12, 6 e 3 ore dall'evento (DD-040). Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
compleanni non c'entrano: non richiedono palloni.
---
@@ -25,16 +26,28 @@ compaiono.
## Implementazione
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
- `eventiPalloni()` (`palloni-core.ts`) ordina gli eventi per data, poi ora, poi id (lo stesso ordine dei job SQL di `m26`: «evento precedente» e «successivo» dipendono da qui) ed è il filtro comune a tutta la pipeline (proposte,
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
scarta eventi extra-campo e compleanni.
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
incaricato in `TurnoPalloni` (scelta della squadra).
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
upsert su `evento_id`.
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
confermate** (partite/eventi) — non è uno storico in tabella dedicata.
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
è il banner in Home per il giocatore di turno.
@@ -42,32 +55,45 @@ compaiono.
## Route API pubblica `/api/public/promemoria-palloni`
Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile,
secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del
giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato
dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il
testo effettivo viene calcolato al volo dal service worker interrogando
`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)).
> **Affiancata da un invio automatico (DD-040, migration `m26`).** Oltre al pulsante, un job
> `pg_cron` ogni 15 minuti manda l'avviso **12, 6 e 3 ore prima** dell'evento, 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
palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso
per ciascuno.
Titolo e testo viaggiano cifrati dentro la push, quindi il service worker li mostra senza
nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
[Notifiche](notifiche.md)).
---
## Limiti noti
- **Nessuna verifica di autenticazione/secret** sulla route `promemoria-palloni`: chiunque
può invocarla via POST diretto, nonostante il piano originale prevedesse una protezione
con secret.
- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal
codice versionato — da verificare lato Supabase/hosting.
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
possono contare turni mai effettivamente convalidati da nessuno.
- **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.
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
dedicato in `palloni-core.test.ts`.
---
## Evoluzioni possibili
- Aggiungere un secret/header di autorizzazione alla route pubblica.
- Versionare il cron (es. una migration con `cron.schedule`) invece di configurarlo solo
lato dashboard.
- Escludere dalla rotazione chi ha già dichiarato assenza per l'evento.
+42 -16
View File
@@ -1,8 +1,8 @@
# Modulo — Presenze
**Stato:** implementato (v1.0)
**Stato:** implementato
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
`src/routes/api/public/sollecita-presenze.ts`
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
---
@@ -24,7 +24,11 @@ attuale.
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
risposto".
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
(`RosaPresenze`, prop `tipo`).
---
@@ -40,25 +44,46 @@ risposte_presenze (Supabase)
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
↓ legge tutta la tabella)
RosaPresenze → src/components/crapp/RosaPresenze.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
allenamento.$id.tsx / partita.$id.tsx
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
partita.$id.tsx / evento.$id.tsx
EventoCard → src/components/crapp/EventoCard.tsx
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
--- statistiche ---
contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
(percentuale ultimi 30gg, da cache già in memoria)
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
--- sollecito (solo admin) ---
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
↓
src/routes/api/public/sollecita-presenze.ts
├─ legge l'evento (eventi_app) e le risposte già date
├─ calcola i destinatari: giocatori attivi senza risposta o con "forse"
├─ per ciascuno registra il messaggio in promemoria_push e invia una push
├─ legge l'evento (eventi_app, con i convocati) e le risposte già date
├─ destinatariSollecito() → src/lib/presenze.ts
│ (convocati, o tutta la rosa se vuoti, senza risposta o con "forse"; funzione pura)
├─ testoSollecito() per ciascuno (risposta attuale e «Richiesta di»)
├─ per ciascuno invia una push col testo cifrato nel payload
│ (src/lib/webpush.server.ts)
└─ elimina le iscrizioni push scadute (404/410)
```
> **Sollecito automatico (DD-040, migration `m26`).** Il pulsante «Sollecita» e la route
> **restano**, ma `destinatariSollecito()` passa a guardare anche i convocati (convocati che non hanno
> risposto o hanno risposto «forse», o tutta la rosa se `convocati` è vuoto); in più un job `pg_cron` manda il sollecito **24, 12 e 6 ore prima** dell'evento a chi in
> quel momento non ha risposto o ha risposto «forse», tra i destinatari dell'evento (convocati, o
> tutta la rosa attiva; mai l'allenatore). Testo e finestre nel [catalogo](notifiche.md#catalogo-delle-notifiche).
> Il diagramma sopra è quello del pulsante, che da DD-040 legge anche `convocati` dell'evento.
Un evento conta ai fini delle statistiche di presenza solo se è di tipo `partita` o
`allenamento` e il giocatore è tra i convocati (o non ci sono convocati specificati, cioè
vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
@@ -69,25 +94,26 @@ vale per tutta la rosa) — `eventiContanoPresenze()` in `presenze.ts`.
- Aggiornamento ottimistico della cache locale dopo ogni salvataggio: nessuna rilettura dal
server, la UI risponde subito.
- Il sollecito è **manuale**: nessun cron nel repository lo richiama automaticamente, parte
solo dal bottone admin.
- Il sollecito ha due vie (DD-040): tre solleciti **automatici** (24, 12 e 6 ore prima, job `pg_cron`
nel database) e il pulsante **manuale** accanto, che non sostituiscono. La route del pulsante verifica
il ruolo lato server con `richiediGestoreEventi` (admin o allenatore, DD-024, DD-034).
- Ognuno risponde **solo per sé**, e non è più una regola della sola interfaccia: dalla
migration `m11_scritture_per_ruolo` la policy di `risposte_presenze` lega la riga allo slot
`giocatori_squadra` collegato all'account, con gli amministratori come sola deroga
(DD-023). Verificato da `test/integration/permessi.test.ts`.
---
## Limiti noti
- Nessuna finestra temporale per rispondere: si può cambiare risposta anche a evento passato.
- Il controllo "solo il giocatore risponde per sé" è solo lato UI: le policy RLS di
`risposte_presenze` permettono a qualunque utente autenticato di scrivere qualunque riga
(`USING(true) WITH CHECK(true)`).
- La risposta di un evento passato resta modificabile: `risposte_presenze` non ha una
finestra di chiusura, né in UI né in RLS.
- `useRispostePresenze()` legge sempre l'intera tabella, non filtrata per evento: adeguato per
una singola squadra, da rivedere se il volume cresce molto.
- La route `/api/public/sollecita-presenze` non verifica lato server che il chiamante sia
admin: la protezione è solo nell'interfaccia (bottone visibile solo se `useIsAdmin()`).
---
## Evoluzioni possibili
- Restringere anche lato RLS/route chi può scrivere una risposta o chiamare il sollecito.
- Filtrare la lettura delle presenze per evento invece di caricare tutta la tabella.
+160 -5
View File
@@ -1,5 +1,11 @@
# Modulo — Profilo Giocatore
**Stato:** implementato
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
`src/routes/admin.tsx`
---
## Obiettivo
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
@@ -8,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
## Utenti
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
la specifica sta in [allenatore.md](allenatore.md).
### Giocatore
Può:
@@ -29,7 +38,8 @@ Può:
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
@@ -54,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
## Home
Il giocatore visualizza un widget dedicato.
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
### Completa il tuo profilo
@@ -70,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
sulla tab Stagione.
### Avviso certificati
**Stato:** implementato (DD-035).
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
già salvata: niente tabella, niente migration, niente push.
#### Chi lo vede
| Utente | Cosa vede | Al tocco |
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
| Altri giocatori | niente | — |
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
nuovo, come il widget «Completa il tuo profilo».
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
solo proprio a un giocatore.
#### Quando compare
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
`GIORNI_AVVISO_CERTIFICATO = 7`.
| Condizione | Stato | Avviso |
| -------------------- | ----------- | ------------------ |
| `giorni > 7` | valido | nessuno |
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno |
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
tab Profili di `/admin` e nel widget di completamento del giocatore.
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
cioè `attivo` e `tipo = 'giocatore'`).
#### Testi
| Avviso | Titolo (staff / personale) | Riga |
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
scadono molti insieme.
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
nero, poi quello giallo.
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
#### Aspetto e posizione
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
di confondersi con lo sfondo.
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
ciò che scade oggi, poi ciò che manca.
#### Rinnovo
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
data, chiunque l'abbia scritta.
#### Caricamento
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
la Home non deve rompersi per un dato accessorio.
#### Implementazione
| Pezzo | Ruolo |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili.
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
di anno nel calcolo dei giorni.
## Profilo
Il profilo viene suddiviso in sette aree.
@@ -85,7 +217,11 @@ Il profilo viene suddiviso in sette aree.
**Dati personali** — modificabili dal giocatore.
- Data di nascita
- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in
`giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione,
visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta
Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera
anche quel valore pubblico.
- Luogo di nascita
- Indirizzo di residenza
- Telefono
@@ -118,6 +254,10 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file.
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
riceve un avviso nella propria Home e compare in quello degli admin (vedi
[Avviso certificati](#avviso-certificati)).
Lo storico non viene mantenuto nella prima versione.
### Foto tessera
@@ -159,9 +299,20 @@ Contiene.
## Dashboard amministratore
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
Profilo → Opzioni).
Profilo → Opzioni), organizzata in tab scorrevoli a pillole (`BarraSottosezioni`, stesso
componente di [Squadra](squadra.md) e Campionato): Squadra, Profili, Disattivati (solo se
c'è almeno un giocatore disattivato) e Notifiche.
Per ogni giocatore vengono mostrati.
La tab **Notifiche** mostra quanti giocatori attivi hanno almeno un dispositivo iscritto
alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (vedi
[Notifiche](notifiche.md)). È solo consultiva: l'attivazione resta un gesto che ogni
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
@@ -230,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
Non carica file al posto di altri.
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
## Versione 1
- Profilo giocatore
@@ -244,6 +398,7 @@ Non carica file al posto di altri.
## Versioni future
- Storico certificati medici
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
- Gestione documenti aggiuntivi
- Consensi privacy
- Firma digitale
+1 -1
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
**Stato:** implementato (fix M7 per la persistenza condivisa)
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
+36 -11
View File
@@ -4,7 +4,9 @@
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
---
@@ -58,6 +60,18 @@ UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
per le conferme.
Le due colonne si confondono facilmente, e sbagliarle non rompe niente di visibile: la serie
comincia solo a raccontare il falso. Per questo il confine è verificato in
`test/integration/scritture.test.ts` («la risposta di presenza si aggiorna senza far ripartire
il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto_il` e controlla
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
| Colonna | Cosa registra | Chi la usa |
| --------------- | --------------------- | ----------------------- |
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
@@ -102,8 +116,11 @@ ordine:
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
quell'evento e la sua serie non si spezza.
2. **Scarta il futuro** (`e.data <= oggi`). Gli eventi di oggi contano già: se serve un
confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data).
2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
Il confronto è **stretto**: l'evento di oggi non conta ancora, perché nessuno ha potuto
presentarsi e conterebbe come assenza, azzerando la serie di tutta la squadra la mattina
della partita. Entra in gioco dal giorno dopo. Il parametro `oggi` è iniettabile — di
default `dataOggi()` — e i test lo fissano a una data per non dipendere dall'orologio.
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
@@ -121,9 +138,10 @@ Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili so
Conseguenze da conoscere prima di cambiare qualcosa:
- **`infortunato` azzera la serie**, esattamente come `assente`. Coerente con il conteggio
presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per
infortunio.
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
@@ -265,9 +283,10 @@ il costo diventerebbe per-render e andrebbe stabilizzata a monte.
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
- **Cambiare la regola di presenza** (per esempio non azzerare su `infortunato`) → il
predicato dentro `serieConsecutiva()`. Valutare se allineare anche
`contaPresenzeGiocatore()`, che oggi usa lo stesso criterio.
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
assenza ai fini statistici.
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
@@ -298,8 +317,14 @@ nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non determini
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle
prime ore della giornata italiana un evento di oggi può risultare "non ancora passato".
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
italiana potevano non essere d'accordo su cosa fosse "oggi".
---
+98
View File
@@ -0,0 +1,98 @@
# Modulo — Squadra
**Stato:** implementato
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
---
## Obiettivo
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
---
## Due letture diverse, per non pagare due volte lo stesso costo
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita
viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`,
DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il
giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null`
finché non è stata inserita da nessuno.
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
(`obiettivi-squadra.md`).
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
## Allenatori in Squadra
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto |
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
dell'admin.
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
all'amministratore.
## Classifica interna di Squadra
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive").
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
---
## Limiti noti
1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
quella reale.
+28
View File
@@ -0,0 +1,28 @@
# Copia in .env (permessi 600) e compila. Il .env non va mai committato.
# Progetto Supabase e chiave service role (salta la RLS: trattala come una password)
SUPABASE_URL=https://<project-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=
# Account Gmail dedicato con verifica in due passaggi e una password per app
SMTP_USER=crapp.notifiche@gmail.com
SMTP_PASS=
# Chiavi VAPID per la push dei promemoria (M24): le stesse VAPID_* dell'app su Vercel, altrimenti i
# dispositivi rifiutano la push. Vanno impostate insieme; se mancano il worker manda solo le email.
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:tuo@indirizzo
# URL pubblico dell'app: base dei link «Apri CrAPP» nelle mail
APP_URL=https://<dominio-dell-app>
# Facoltative (valori di default)
# MAIL_FROM_NAME=CrAPP
# MAIL_FROM= # default: SMTP_USER
# POLL_SECONDI=30
# MAIL_LIMITE_GIORNO=400 # tetto delle ultime 24 ore, sotto i ~500 di Gmail
# MAIL_LOTTO=10
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false # true solo per la porta 465
+11
View File
@@ -0,0 +1,11 @@
# Contesto di build: la radice del repository (vedi docker-compose.yml e Dockerfile.dockerignore).
# Il layout dentro l'immagine rispecchia quello del repository, così mailer.mjs importa
# ../src/lib/webpush.server.ts allo stesso modo in locale e nel container.
FROM oven/bun:1-alpine
WORKDIR /app/mailer
COPY mailer/package.json mailer/bun.lock mailer/bunfig.toml ./
RUN bun install --frozen-lockfile --production
COPY mailer/mailer.mjs mailer/mailer-core.ts mailer/healthcheck.mjs ./
COPY src/lib/webpush.server.ts /app/src/lib/webpush.server.ts
USER bun
CMD ["bun", "mailer.mjs"]
+11
View File
@@ -0,0 +1,11 @@
# Il contesto di build è la radice del repository (serve src/lib/webpush.server.ts, lo stesso modulo
# push dell'app): si esclude tutto e si ammettono solo i file che l'immagine usa. Così non finiscono
# nel contesto né il .env né il resto del progetto.
*
!mailer/package.json
!mailer/bun.lock
!mailer/bunfig.toml
!mailer/mailer.mjs
!mailer/mailer-core.ts
!mailer/healthcheck.mjs
!src/lib/webpush.server.ts
+15
View File
@@ -0,0 +1,15 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "crapp-mailer",
"dependencies": {
"nodemailer": "^10.0.11",
},
},
},
"packages": {
"nodemailer": ["nodemailer@10.0.11", "", {}, "sha512-/c4P7U7aGpiNu2Rl08q525q2zECkUpSaf2zQPgIStCcAjHAkkQQKwD4sTyi3l91mGcLQrKzL1syXPHymo+Ogtw=="],
}
}
+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"
}
}
-4
View File
@@ -1,4 +0,0 @@
Le regole di progetto non vivono più qui: sono in `docs/`.
- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md`
- Portabilità → `docs/PORTABILITA.md`
+4 -3
View File
@@ -1,5 +1,6 @@
{
"name": "tanstack_start_ts",
"name": "crapp",
"version": "1.2.1",
"private": true,
"sideEffects": false,
"type": "module",
@@ -9,6 +10,7 @@
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"test": "bun test/run.ts",
"test:integration": "bun test/run.ts integration",
@@ -16,7 +18,6 @@
"test:all": "bun test/run.ts all"
},
"dependencies": {
"@lovable.dev/cloud-auth-js": "^1.1.2",
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
@@ -38,7 +39,7 @@
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.8.5",
"@tanstack/devtools-vite": "^0.8.3",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
+22 -27
View File
@@ -1,36 +1,31 @@
// Service worker dedicato alle notifiche push del turno palloni.
// Service worker dedicato alle notifiche push di CrAPP.
// Non memorizza nella cache pagine o asset dell'app.
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("install", (event) => event.waitUntil(self.skipWaiting()));
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
async function testoNotifica() {
try {
const sub = await self.registration.pushManager.getSubscription();
if (!sub) return null;
const res = await fetch("/api/public/push-messaggio", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
self.addEventListener("push", (event) => {
// Il testo arriva cifrato dentro la push: nessuna rete, nessuna attesa. A dispositivo
// dormiente il browser sveglia il worker per pochi secondi e una fetch di troppo lo fa
// terminare prima di `showNotification` — la notifica non appare affatto, mentre ad app
// aperta, con la rete calda, sembra funzionare tutto.
let dati = null;
try {
dati = event.data?.json() ?? null;
} catch {
dati = null;
}
event.waitUntil(
(async () => {
const dati = await testoNotifica();
await self.registration.showNotification(dati?.title ?? "CrAPP · Turno palloni", {
body: dati?.body ?? "Controlla il turno palloni di oggi.",
icon: "/icon-192.png",
badge: "/icon-192.png",
tag: "turno-palloni",
});
})(),
self.registration.showNotification(dati?.title ?? "CrAPP", {
body: dati?.body ?? "Apri l'app per i dettagli.",
icon: "/icon-192.png",
badge: "/icon-192.png",
// Il tag fa sostituire la notifica precedente invece di accumularne una pila;
// `renotify` fa sì che la sostituzione avvisi di nuovo, altrimenti la seconda
// notifica comparirebbe in silenzio e sembrerebbe non essere mai arrivata.
tag: "crapp-notifica",
renotify: true,
}),
);
});
+113
View File
@@ -0,0 +1,113 @@
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificati,
formatDataBreve,
testoScadenza,
type AvvisoCertificato,
} from "@/lib/profili-core";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { oggiISO } from "@/lib/palloni-core";
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
function Card({
colore,
titolo,
righe,
cliccabile,
}: {
colore: "warning" | "primary";
titolo: string;
righe: string[];
cliccabile?: boolean;
}) {
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning"
? "bg-warning text-warning-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`;
const contenuto = (
<>
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> {titolo}
</p>
{righe.map((r) => (
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
{r}
</p>
))}
</>
);
return cliccabile ? (
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
{contenuto}
</Link>
) : (
<div className={classi}>{contenuto}</div>
);
}
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
export function AvvisoCertificati() {
const admin = useIsAdmin();
const ruoliPronti = useRuoliPronti();
const base = useGiocatoreBase();
const { righe: rosa } = useGiocatoriSquadra();
const { profili, isPending } = useProfili();
if (!ruoliPronti || isPending) return null;
const oggi = oggiISO();
if (admin) {
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
if (scaduti.length === 0 && inScadenza.length === 0) return null;
return (
<>
{scaduti.length > 0 ? (
<Card
colore="primary"
titolo="Certificati scaduti"
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Card
colore="warning"
titolo="Certificati in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
if (!base) return null;
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
const mio = scaduti[0] ?? inScadenza[0];
if (!mio) return null;
return mio.giorni < 0 ? (
<Card
colore="primary"
titolo="Certificato scaduto"
cliccabile
righe={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Card
colore="warning"
titolo="Certificato in scadenza"
cliccabile
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
/>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
}
+98 -48
View File
@@ -1,46 +1,60 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
import { motion, type Transition } from "motion/react";
import { cn } from "@/lib/utils";
import { proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export type VoceSottosezione = {
id: string;
label: string;
contenuto: ReactNode;
/** Se true, il pannello è a tutta larghezza (niente padding), es. barra filtri Classifica. */
aTuttoLarghezza?: boolean;
};
/** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */
const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] };
/**
* Barra di sottosezioni in un'unica fila scorrevole (swipe orizzontale) e
* pannello che mostra una sola sezione alla volta, cambiabile anche con
* swipe sul contenuto.
* Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione
* alla volta, cambiabile anche con swipe sul contenuto.
*
* Il pannello precedente si smonta subito (niente AnimatePresence/exit):
* al cambio tab resta un solo albero da animare in ingresso.
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
* la barra non viene mai mostrato: l’etichetta è già nella tab.
*/
export function BarraSottosezioni({
voci,
defaultId,
variante = "pillole",
riempiLarghezza = false,
}: {
voci: VoceSottosezione[];
defaultId?: string;
variante?: "pillole" | "sottolineatura";
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
riempiLarghezza?: boolean;
}) {
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
const direzione = useRef(0);
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
// sottosezioni (Squadra, Campionato, Profilo).
const ridotto = useMotoRidotto();
const indice = Math.max(
0,
voci.findIndex((v) => v.id === attiva),
);
const voce = voci[indice] ?? voci[0];
const sottolineatura = variante === "sottolineatura";
useEffect(() => {
// `auto`: lo smooth competerebbe col tween del pannello sul main thread.
tabRefs.current[attiva]?.scrollIntoView({
behavior: "auto",
behavior: "smooth",
inline: "center",
block: "nearest",
});
@@ -57,55 +71,92 @@ export function BarraSottosezioni({
if (!voce) return null;
return (
<div>
<div className="border-b border-border bg-background/80 pt-3 backdrop-blur-md">
<div className="min-w-0 max-w-full">
<div
className={cn(
"min-w-0 max-w-full border-b border-border bg-background",
!sottolineatura &&
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
)}
>
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
<div
role="tablist"
aria-label="Sottosezioni"
className="-mx-0 flex snap-x snap-mandatory gap-1.5 overflow-x-auto px-5 pb-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
className={cn(
"w-full max-w-full min-w-0 overflow-x-auto overflow-y-hidden [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
!sottolineatura && "pb-3",
)}
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
// grow+basis-0: se le voci ci stanno riempiono la riga in parti uguali,
// altrimenti shrink-0 le tiene leggibili e la barra torna a scorrere.
"snap-center shrink-0 grow basis-0 rounded-full px-3.5 py-2 text-xs font-bold uppercase tracking-wide transition-colors",
"min-h-11 touch-manipulation whitespace-nowrap",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
)}
>
{v.label}
</button>
);
})}
<div
role="tablist"
aria-label="Sottosezioni"
className={cn(
riempiLarghezza
? "flex w-full flex-nowrap"
: sottolineatura
? "flex w-max min-w-full flex-nowrap"
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
)}
>
{voci.map((v) => {
const selezionata = v.id === attiva;
return (
<button
key={v.id}
ref={(el) => {
tabRefs.current[v.id] = el;
}}
type="button"
role="tab"
aria-selected={selezionata}
onClick={() => {
const i = voci.findIndex((x) => x.id === v.id);
vaiA(i);
}}
className={cn(
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full",
sottolineatura
? cn(
"rounded-xl px-1 py-2.5 text-center",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "text-muted-foreground",
)
: cn(
"rounded-full py-2 text-center",
riempiLarghezza ? "px-2" : "px-3.5",
selezionata
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-muted-foreground",
),
)}
>
{v.label}
</button>
);
})}
</div>
</div>
</div>
<div className="relative overflow-hidden">
<div className="relative min-w-0 overflow-hidden">
<motion.div
key={voce.id}
role="tabpanel"
aria-label={voce.label}
drag="x"
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.12}
dragMomentum={false}
onDragEnd={(_, info) => {
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) vaiA(indice + 1);
else if (arrivo > 60) vaiA(indice - 1);
@@ -113,9 +164,8 @@ export function BarraSottosezioni({
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione.current * 20 }}
animate={{ opacity: 1, x: 0 }}
transition={ridotto ? { duration: 0.1 } : transizioneTab}
className="touch-pan-y px-5 py-4"
className={cn("touch-pan-y", voce.aTuttoLarghezza ? "px-0 pt-0 pb-4" : "px-5 py-4")}
>
<h2 className="mb-3 font-display-sm text-lg uppercase">{voce.label}</h2>
{voce.contenuto}
</motion.div>
</div>
+18 -4
View File
@@ -1,7 +1,8 @@
import { Link } from "@tanstack/react-router";
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { motion } from "motion/react";
import { molla } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
// Quattro voci e non cinque: il profilo sta in alto a destra
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
@@ -9,11 +10,14 @@ const items = [
{ to: "/", label: "Home", icon: Home },
{ to: "/calendario", label: "Calendario", icon: CalendarDays },
{ to: "/squadra", label: "Squadra", icon: Users },
{ to: "/classifica", label: "Classifica", icon: Trophy },
{ to: "/classifica", label: "Campionato", icon: Trophy },
] as const;
export function BottomNav() {
const ridotto = useReducedMotion();
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
const ridotto = useMotoRidotto();
return (
<nav
@@ -62,11 +66,21 @@ export function BottomNav() {
*/}
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
<div
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
{...(ridotto ? { "data-leggero": "" } : {})}
>
{items.map(({ to, label, icon: Icon }) => (
<Link
key={to}
to={to}
// Il chunk della pagina parte già al tocco (touchstart), non al tap completo:
// le 4 rotte sono piccole (8-12KB), nessun costo aggiuntivo di query (nessuna
// rotta usa `loader`, quindi non prefetcha dati, solo codice).
preload="intent"
activeOptions={{ exact: to === "/" }}
activeProps={{ "aria-current": "page" }}
// Lo stato attivo non è solo colore: è la capsula piena sotto la
+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { ExternalLink, ShieldAlert, Users2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { useCsiPartita } from "@/lib/csi-partita";
import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core";
/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */
export function LogoSquadra({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const [errore, setErrore] = useState(false);
if (!src || errore) return null;
return (
<img
src={src}
alt={alt}
loading="lazy"
onError={() => setErrore(true)}
className={cn("shrink-0 rounded-lg object-contain", className)}
/>
);
}
/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */
export function MetaPartitaCsi({
girone,
numeroGara,
arbitro,
link,
}: {
girone: string;
numeroGara: string;
arbitro: string;
link: string;
}) {
const voci = [
girone,
numeroGara ? `N° gara ${numeroGara}` : "",
arbitro ? `Arbitro: ${arbitro}` : "",
]
.filter(Boolean)
.join(" · ");
if (!voci && !link) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{voci ? <span>{voci}</span> : null}
{link ? (
<a
href={link}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 font-semibold text-accent"
>
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
);
}
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
return (
<div>
<p className="text-xs 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">
{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}
</div>
))}
</div>
{squadra.panchina.length > 0 ? (
<>
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
A disposizione
</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">
<span className="w-6 shrink-0 text-center">{g.numero}</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
</div>
))}
</div>
</>
) : null}
{squadra.staff.length > 0 ? (
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
{squadra.staff.map((s, i) => (
<div key={i}>
{s.nome} · {s.ruolo}
</div>
))}
</div>
) : null}
</div>
);
}
function BarraPrecedenti({
label,
noi,
avversario,
}: {
label: string;
noi: number;
avversario: number;
}) {
const totale = noi + avversario || 1;
return (
<div className="text-xs">
<div className="flex items-center justify-between text-muted-foreground">
<span className="font-bold text-foreground">{noi}</span>
<span>{label}</span>
<span className="font-bold text-foreground">{avversario}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
</div>
</div>
);
}
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">
<span className="truncate text-accent">{NOME_NOI}</span>
<span className="truncate text-right text-muted-foreground">{avversario}</span>
</div>
);
}
function BarraProbabilita({
avversario,
noi,
avversarioPct,
}: {
avversario: string;
noi: number;
avversarioPct: number;
}) {
const favoritaNoi = noi >= avversarioPct;
return (
<div>
<p className="mb-2 text-xs 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)}%.
</p>
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
{avversario}
</span>
</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"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
style={{ width: `${avversarioPct}%` }}
>
{avversarioPct.toFixed(0)}%
</div>
</div>
</div>
);
}
function BloccoPrecedenti({
precedenti,
avversario,
}: {
precedenti: PrecedentiCsi;
avversario: string;
}) {
return (
<div className="space-y-3">
<TestataSquadre avversario={avversario} />
<p className="text-xs 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."}
</p>
{precedenti.totale > 0 ? (
<>
<BarraPrecedenti
label="vittorie"
noi={precedenti.vinteNoi}
avversario={precedenti.vinteAvversario}
/>
<BarraPrecedenti
label="in casa"
noi={precedenti.casaNoi}
avversario={precedenti.casaAvversario}
/>
<BarraPrecedenti
label="fuori"
noi={precedenti.fuoriNoi}
avversario={precedenti.fuoriAvversario}
/>
</>
) : null}
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
<BarraProbabilita
avversario={avversario}
noi={precedenti.probabilitaNoi}
avversarioPct={precedenti.probabilitaAvversario}
/>
) : null}
</div>
);
}
/**
* Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato
* server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una
* lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve
* solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI).
*/
export function DettaglioCsiEsteso({
matchId,
avversario,
}: {
matchId: string;
avversario: string;
}) {
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>;
}
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">
{[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">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ListaFormazione squadra={data.formazioni.noi} />
<ListaFormazione squadra={data.formazioni.avversario} />
</div>
</Card>
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
</div>
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
</Card>
) : null}
</div>
);
}
+159 -85
View File
@@ -1,33 +1,66 @@
import { MapPin, Clock, Users, Cake, ArrowRight } from "lucide-react";
import {
MapPin,
Clock,
Users,
Cake,
ChevronRight,
Check,
HelpCircle,
X,
Bandage,
} from "lucide-react";
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 { Barra } from "@/components/motion/Barra";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
partita: { label: "Partita", className: "bg-accent text-accent-foreground" },
allenamento: { label: "Allenamento", className: "bg-training text-training-foreground" },
evento: { label: "Eventi", className: "bg-warning text-warning-foreground" },
evento: { label: "Evento", className: "bg-warning text-warning-foreground" },
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
} as const;
const stati: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
const iconeStato: Record<Stato, typeof Check> = {
presente: Check,
forse: HelpCircle,
ritardo: Clock,
assente: X,
/** Cerotto singolo: leggibile a 16px e allineato all'emoji 🩹. */
infortunato: Bandage,
};
export function linkPerEvento(e: Evento) {
if (e.tipo === "partita") {
return { to: "/partita/$id", params: { id: e.id }, label: "Apri partita" };
return { to: "/partita/$id" as const, params: { id: e.id }, label: "Dettaglio partita" };
}
if (e.tipo === "allenamento") {
return { to: "/allenamento/$id", params: { id: e.id }, label: "Apri allenamento" };
return {
to: "/allenamento/$id" as const,
params: { id: e.id },
label: "Dettaglio allenamento",
};
}
if (e.tipo === "evento") {
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
}
return undefined;
}
function etichettaData(iso: string) {
const giorno = iso.slice(8, 10);
const mese = formatData(iso).split(" ")[2]?.slice(0, 3)?.toUpperCase() ?? "";
return `${giorno} ${mese}`;
}
export function EventoCard({
evento,
linkTo,
@@ -37,9 +70,12 @@ export function EventoCard({
}) {
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -49,8 +85,14 @@ export function EventoCard({
? { label: "Amichevole", className: "bg-accent/70 text-accent-foreground" }
: tipoMeta[evento.tipo];
const totale = rosa.length;
const perc = totale ? Math.round((presentiVeri / totale) * 100) : 0;
const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
const stati = includeInfortunato(evento.tipo, stato)
? [...statiBase, "infortunato" as const]
: statiBase;
if (isCompleanno) {
return (
@@ -62,20 +104,39 @@ export function EventoCard({
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
</div>
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
<p className="text-xs font-semibold uppercase text-muted-foreground">
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
</p>
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
</div>
</Card>
);
}
const sottotitoloPartita =
evento.tipo === "partita"
? `${evento.casa ? "Casa" : "Trasferta"}${evento.campionato ? " · Campionato" : " · Amichevole"}`
: null;
const metaStato = stato ? statoMeta[stato] : null;
return (
<Card>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Card
as="article"
className={cn(
"relative overflow-hidden p-0",
cliccabile && "transition-transform active:scale-[0.99]",
)}
>
{/* Link a tutta card: i controlli sopra (z-10) restano indipendenti. */}
{linkTo ? (
<Link
to={linkTo.to}
params={linkTo.params}
aria-label={linkTo.label}
className="absolute inset-0 z-0 rounded-3xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
) : null}
<div className={cn("relative z-10 p-4", linkTo && "pointer-events-none")}>
<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",
@@ -84,76 +145,89 @@ export function EventoCard({
>
{tipo.label}
</span>
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
</span>
</div>
<div className="shrink-0 rounded-2xl bg-secondary px-3 py-2 text-center">
<p className="font-display text-xl leading-none">{evento.data.slice(8, 10)}</p>
<p className="text-xs font-semibold uppercase text-muted-foreground">
{formatData(evento.data).split(" ")[2]?.slice(0, 3)}
</p>
</div>
</div>
{linkTo && (
<div className="mt-2 flex justify-end">
<Link
to={linkTo.to}
params={linkTo.params}
className="inline-flex min-h-11 items-center gap-1 rounded-full bg-primary px-4 text-xs font-bold uppercase tracking-wide text-primary-foreground transition-transform active:scale-95"
<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>
) : null}
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] 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>
</span>
<span className="inline-flex min-w-0 items-center gap-1">
<MapPin className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate">{evento.luogo}</span>
</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>
{presentiVeri}/{totale}
</span>
</span>
</div>
{noteCard ? (
<p className="mt-2.5 line-clamp-2 text-xs 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"
aria-live="polite"
>
{linkTo.label} <ArrowRight className="h-3 w-3" />
</Link>
</div>
)}
<span aria-hidden>{metaStato.emoji}</span>
<span>{metaStato.label}</span>
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> {evento.ora}
</span>
<span className="inline-flex min-w-0 items-center gap-1">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{evento.luogo}</span>
</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" /> {presentiVeri}/{totale}
</span>
{io ? (
<div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group"
aria-label="La tua presenza"
>
{stati.map((s) => {
const meta = statoMeta[s];
const Icon = iconeStato[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending || passato}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
});
}}
aria-pressed={attivo}
aria-label={meta.label}
title={meta.label}
className={cn(
"inline-flex min-h-11 min-w-0 flex-1 items-center justify-center rounded-xl px-1 transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(meta.className, "shadow-card")
: "bg-secondary text-muted-foreground",
)}
>
<Icon className="h-4 w-4 shrink-0" aria-hidden />
</button>
);
})}
</div>
) : null}
</div>
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-3" />
{io ? (
<div className="mt-4 flex flex-wrap gap-2">
{stati.map((s) => {
const meta = statoMeta[s];
const attivo = stato === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending}
onClick={() =>
salva.mutate({
eventoId: evento.id,
giocatoreId: io.id,
stato: attivo ? null : s,
})
}
aria-pressed={attivo}
className={cn(
// min-h-11: sono i controlli più toccati dell'app, sotto i
// 44px si sbaglia bersaglio.
"min-h-11 rounded-full border border-border px-3 text-xs font-semibold transition-all active:scale-95",
attivo
? cn(meta.className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
)}
>
{meta.label}
</button>
);
})}
</div>
) : null}
</Card>
);
}
+3 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,7 +20,8 @@ export function Pagelle({
convocati: Giocatore[];
chiuse?: boolean;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null);
+149 -13
View File
@@ -3,7 +3,7 @@ import { Link } from "@tanstack/react-router";
import { Check, Eye, Loader2, Upload } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, SezioneTendina } from "@/components/crapp/ui-bits";
import { Campo, classiInput, Select, SezioneTendina } from "@/components/crapp/ui-bits";
import { Reveal } from "@/components/motion/Reveal";
import {
caricaFile,
@@ -12,8 +12,10 @@ import {
useSalvaProfilo,
type SezioneFile,
} from "@/lib/profili";
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
import {
completamento,
completamentoAllenatore,
profiloVuoto,
sezioniComplete,
type Profilo,
@@ -180,10 +182,9 @@ export function CampiProfilo({
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Tipo">
<select
<Select
value={corrente.documentoTipo ?? ""}
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
className={classiInput}
>
<option value="">—</option>
{TIPI_DOCUMENTO.map((t) => (
@@ -191,7 +192,7 @@ export function CampiProfilo({
{t}
</option>
))}
</select>
</Select>
</Campo>
<Campo label="Numero">
<input
@@ -245,6 +246,138 @@ export function CampiProfilo({
);
}
/**
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
*/
export function CampiAllenatore({
corrente,
aggiorna,
}: {
corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void;
}) {
return (
<>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita">
<input
type="date"
value={corrente.dataNascita ?? ""}
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Luogo di nascita">
<input
value={corrente.luogoNascita ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
</>
);
}
/**
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
* ridotti. Un solo pulsante salva entrambi.
*/
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
const { profili } = useProfili();
const salvaProfilo = useSalvaProfilo();
const salvaNome = useSalvaNomeAllenatore();
const [bozza, setBozza] = useState<Profilo | null>(null);
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
const sporco = bozza !== null || nome !== null;
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
const perc = completamentoAllenatore(corrente);
async function salva() {
try {
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
if (bozza) await salvaProfilo.mutateAsync(bozza);
setNome(null);
setBozza(null);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
return (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center justify-between gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
{perc}%
</span>
</div>
<Intestazione titolo="Dati personali" completa={perc === 100} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
value={nomeCorrente.nome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Cognome">
<input
value={nomeCorrente.cognome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
<button
type="button"
onClick={() => void salva()}
disabled={!sporco || inCorso}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
</button>
</div>
);
}
/**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
@@ -305,10 +438,6 @@ export function ProfiloAmministrativo({
</span>
</div>
<p className="text-xs text-muted-foreground">
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p>
<CampiProfilo
corrente={corrente}
aggiorna={aggiorna}
@@ -385,13 +514,18 @@ export function ProfiloAmministrativo({
*/
export function CompletaProfilo({
giocatoreId,
allenatore = false,
indice = 0,
}: {
giocatoreId: string;
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
allenatore?: boolean;
indice?: number;
}) {
const { profili, isPending } = useProfili();
const perc = completamento(profili[giocatoreId]);
const perc = allenatore
? completamentoAllenatore(profili[giocatoreId])
: completamento(profili[giocatoreId]);
if (isPending || perc === 100) return null;
return (
@@ -402,10 +536,10 @@ export function CompletaProfilo({
className="premi block rounded-3xl bg-card p-4 shadow-card"
>
<div className="flex items-center justify-between gap-3">
<span className="font-display text-sm uppercase tracking-wide">
<span className="font-display text-[15px] uppercase tracking-wide">
Completa il tuo profilo
</span>
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
@@ -413,8 +547,10 @@ export function CompletaProfilo({
style={{ width: `${perc}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
<p className="mt-2 text-[13px] text-muted-foreground">
{allenatore
? "Data e luogo di nascita, telefono ed email."
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
</p>
</Link>
</Reveal>
+3 -2
View File
@@ -3,11 +3,12 @@ import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
const io = useGiocatoreInCampo();
const { turni } = useTurniPalloni();
const { eventi } = useEventi();
if (!io) return null;
+35 -17
View File
@@ -6,33 +6,51 @@ import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import type { EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
export function RosaPresenze({ eventoId }: { eventoId: string }) {
export function RosaPresenze({
eventoId,
data,
tipo = "allenamento",
}: {
eventoId: string;
data: string;
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo;
}) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
const rosa = useRosa();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreInCampo();
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
const mancanti = rosa.filter((g) => !risposte[g.id]);
const risposteN = rosa.length - mancanti.length;
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
async function sollecita() {
setSollecito(true);
try {
const res = await fetch("/api/public/sollecita-presenze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventoId, da: io?.nome }),
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: mittente?.nome }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
@@ -60,7 +78,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
<div className="mt-3 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const n = rosa.filter((g) => risposte[g.id] === s).length;
return (
<span
@@ -85,18 +103,18 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
La tua risposta
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const attivo = risposte[io.id] === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending}
disabled={salva.isPending || passato}
onClick={() =>
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95",
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(statoMeta[s].className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
@@ -110,11 +128,11 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
</div>
) : null}
{admin ? (
{puoSollecitare ? (
<button
type="button"
onClick={sollecita}
disabled={sollecito || daSollecitare === 0}
disabled={sollecito || daSollecitare === 0 || passato}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
>
{sollecito ? (
@@ -133,7 +151,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
</p>
) : null}
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const lista = rosa.filter((g) => risposte[g.id] === s);
if (lista.length === 0) return null;
return (
@@ -166,7 +184,7 @@ function Gruppo({
}: {
titolo: string;
n: number;
lista: Giocatore[];
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
attenzione?: boolean;
}) {
return (
+3 -2
View File
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
import { ChevronRight, Lock, Radio } from "lucide-react";
import { cn } from "@/lib/utils";
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreBase } from "@/lib/user-store";
/**
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
@@ -17,7 +17,8 @@ export function ScoutEntry({
}) {
const { pronto, partita: diOggi } = usePartitaDiOggi();
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
const io = useGiocatoreCorrente();
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const { data: sessione } = useSessioneScout(partita?.id ?? null);
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
+19 -7
View File
@@ -4,9 +4,16 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { mediaPartita, sondaggioAperto, useCacche, useSalvaCacche } from "@/lib/cacche";
import {
mediaPartita,
sondaggioAperto,
sondaggioTerminato,
useCacche,
useSalvaCacche,
} from "@/lib/cacche";
const opzioni = [0, 1, 2, 3, 4, 5];
@@ -14,11 +21,14 @@ const opzioni = [0, 1, 2, 3, 4, 5];
export function SondaggioCacche({
eventoId,
dataEvento,
oraEvento,
}: {
eventoId: string;
dataEvento: string;
oraEvento: string;
}) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
@@ -51,7 +61,7 @@ export function SondaggioCacche({
try {
const res = await fetch("/api/public/apri-sondaggio", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId }),
});
if (!res.ok) throw new Error();
@@ -59,7 +69,7 @@ export function SondaggioCacche({
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con le notifiche attive",
: "Avviso inviato in app e per email (nessun dispositivo con le notifiche attive)",
);
} catch {
toast.error("Non sono riuscito a inviare la notifica");
@@ -68,14 +78,16 @@ export function SondaggioCacche({
}
}
if (!sondaggioAperto(dataEvento)) {
if (!sondaggioAperto(dataEvento, oraEvento)) {
return (
<Card>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<p className="mt-1 text-xs text-muted-foreground">
Apre alle 8:00 del giorno della partita: riceverai una notifica.
{sondaggioTerminato(dataEvento, oraEvento)
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
</p>
</Card>
);
+54 -5
View File
@@ -1,19 +1,24 @@
import { useState } from "react";
import { Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { BellRing, Check, CircleDot, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { intestazioniAutenticate } from "@/lib/auth";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const [aperto, setAperto] = useState(false);
const [avviso, setAvviso] = useState(false);
const { salvati, turni, isPending } = useTurniPalloni();
const assegna = useAssegnaTurno();
const io = useGiocatoreCorrente();
// Solo `.nome` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const id = turni[eventoId];
const proposto = !salvati[eventoId];
@@ -30,6 +35,34 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
);
}
/** Avvisa via push chi è di turno per questo evento, e chi deve riportare i palloni. */
async function avvisa() {
setAvviso(true);
try {
const res = await fetch("/api/public/promemoria-palloni", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
if (dati.inviate > 0) {
toast.success(`Promemoria inviato a ${dati.inviate} dispositivi`);
} else if (dati.destinatari === 0) {
toast.info("Nessun dispositivo con le notifiche attive tra gli incaricati");
} else {
// I dispositivi ci sono ma l'invio non è riuscito: quasi sempre le chiavi VAPID
// mancanti (in locale non sono configurate). Dirlo, invece di dare la colpa
// all'assenza di iscrizioni.
toast.error(`Invio non riuscito verso ${dati.destinatari} dispositivi`);
}
} catch {
toast.error("Non sono riuscito a inviare il promemoria");
} finally {
setAvviso(false);
}
}
return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-2.5">
<button
@@ -78,6 +111,22 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
))}
</div>
) : null}
{admin && giocatore ? (
<button
type="button"
onClick={avvisa}
disabled={avviso}
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
>
{avviso ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<BellRing className="h-3.5 w-3.5" />
)}
Avvisa chi è di turno
</button>
) : null}
</div>
);
}
+66 -24
View File
@@ -2,17 +2,37 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi";
import {
conteggioPartita,
mioVoto,
ORE_ATTESA_MVP,
useVotaMvp,
useVotiMvp,
votoMvpAperto,
type VotoMvp,
} from "@/lib/mvp-voti";
/** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */
export function VotazioneMvp({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
/**
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
*
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
* solo i presenti (o in ritardo) di quell'evento.
*/
export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id;
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? [];
@@ -21,6 +41,20 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
const testa = conteggio[0];
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
const puoVotare = !!io && presente(io.id);
if (!votoMvpAperto(evento.data, evento.ora)) {
return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP
</p>
<p className="mt-1 text-xs text-muted-foreground">
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
</p>
</div>
);
}
async function invia(id: string, nome: string) {
if (!io) return;
@@ -47,7 +81,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
<button
type="button"
onClick={() => setAperto((v) => !v)}
disabled={!io}
disabled={!puoVotare}
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{mio ? "Cambia voto" : "Vota"}
@@ -67,26 +101,34 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
</p>
{mio ? (
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
) : !puoVotare ? (
<p className="mt-1 text-xs text-muted-foreground">
Vota chi era presente a questa partita.
</p>
) : null}
{aperto ? (
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
{rosa.map((g) => (
<button
key={g.id}
type="button"
disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
mio?.votato_id === g.id
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
)}
>
{nomeCompleto(g)}
</button>
))}
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
anche dal vincolo `mvp_no_autovoto` (M12). */}
{rosa
.filter((g) => g.id !== io?.id)
.map((g) => (
<button
key={g.id}
type="button"
disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
mio?.votato_id === g.id
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
)}
>
{nomeCompleto(g)}
</button>
))}
</div>
) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
+5 -4
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import {
categorieSocial,
conteggioCategoria,
@@ -15,11 +15,12 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) {
const io = useGiocatoreCorrente();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const [aperta, setAperta] = useState<string | null>(null);
const tutti = voti.data ?? [];
+217 -21
View File
@@ -1,9 +1,27 @@
import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
import {
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { Link } from "@tanstack/react-router";
import { ChevronDown } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { statoMeta, type Stato } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useMotoRidotto } from "@/lib/motion";
import { molla, proietta } from "@/lib/molla";
import {
pallinoNotifiche,
useEliminaNotifica,
useNotificheMie,
useSegnaLette,
type NotificaUtente,
} from "@/lib/notifiche-utente";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
@@ -25,6 +43,7 @@ export function TeamLogo({
alt="CRAP Volley"
width={192}
height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
@@ -46,22 +65,176 @@ export function Card({
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
}
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
function tempoRelativo(dataIso: string): string {
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
const ore = Math.round(minuti / 60);
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
return rtf.format(Math.round(ore / 24), "day");
}
/**
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
* c'è "indietro", solo "via".
*/
function RigaNotifica({
notifica,
onElimina,
}: {
notifica: NotificaUtente;
onElimina: (id: string) => void;
}) {
const ridotto = useMotoRidotto();
return (
<motion.li
layout={!ridotto}
initial={false}
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
transition={molla.ui}
className="relative overflow-hidden rounded-xl"
>
<motion.div
drag={ridotto ? false : "x"}
dragConstraints={{ left: -80, right: 0 }}
dragElastic={{ left: 0.2, right: 0 }}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) onElimina(notifica.id);
}}
className="touch-pan-y bg-card p-3 pr-8 text-sm"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? (
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
onClick={() => onElimina(notifica.id)}
aria-label="Rimuovi notifica"
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.li>
);
}
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
* `PageHeader` per rimetterci il logo — sarebbe un link a sé stessa.
* `PageHeader` con il logo che porta alla home.
*
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
*
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
* porta sempre al profilo, quello sul pallino apre il pannello.
*/
export function LinkProfilo() {
const g = useIo();
if (!g) return <TeamLogo className="h-11 w-11" />;
const g = useGiocatoreBase();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={g.iniziali} className="h-11 w-11 text-lg" />
</Link>
<div className="relative shrink-0">
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
<PallinoNotifiche />
</div>
);
}
/**
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
* `src/lib/notifiche-utente.ts`.
*
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
*/
function PallinoNotifiche() {
const { notifiche, nonLette } = useNotificheMie();
const segnaLette = useSegnaLette();
const eliminaNotifica = useEliminaNotifica();
const [aperto, setAperto] = useState(false);
const riquadro = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!aperto) return;
const chiudiSeFuori = (e: MouseEvent) => {
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
};
const chiudiConEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setAperto(false);
};
document.addEventListener("mousedown", chiudiSeFuori);
document.addEventListener("keydown", chiudiConEsc);
return () => {
document.removeEventListener("mousedown", chiudiSeFuori);
document.removeEventListener("keydown", chiudiConEsc);
};
}, [aperto]);
const pallino = pallinoNotifiche(notifiche.length, nonLette);
if (!pallino) return null;
function alClick() {
const stavaChiuso = !aperto;
setAperto(stavaChiuso);
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
}
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
// notifica non si riapre da solo.
function elimina(id: string) {
if (notifiche.length === 1) setAperto(false);
eliminaNotifica.mutate(id);
}
return (
<div ref={riquadro}>
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
<button
type="button"
onClick={alClick}
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
aria-expanded={aperto}
className={cn(
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
pallino.daLeggere
? "bg-accent-grad text-accent-foreground"
: "bg-muted text-muted-foreground",
)}
>
{pallino.testo}
</button>
{aperto ? (
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
<ul className="max-h-80 overflow-y-auto">
<AnimatePresence initial={false}>
{notifiche.map((n) => (
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
))}
</AnimatePresence>
</ul>
</div>
) : null}
</div>
);
}
@@ -96,17 +269,19 @@ export function Section({
children,
indice = 0,
}: {
titolo: string;
titolo?: string;
azione?: ReactNode;
children: ReactNode;
indice?: number;
}) {
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
{azione}
</div>
{titolo || azione ? (
<div className="mb-3 flex items-center justify-between gap-3">
{titolo ? <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2> : <span />}
{azione}
</div>
) : null}
{children}
</Reveal>
);
@@ -156,9 +331,14 @@ export function SezioneTendina({
);
}
/** Classi condivise di input, select e textarea nei form dell'app. */
/**
* Classi condivise di input, select e textarea nei form dell'app. `h-10`:
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
* con `appearance-none`, restano un filo più alti di un input di testo a
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
*/
export const classiInput =
"w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
/**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
@@ -177,6 +357,22 @@ export function Campo({ label, children }: { label: string; children: ReactNode
);
}
/**
* `<select>` nativo con la stessa altezza degli altri campi del form. Il
* controllo nativo di un `<select>` ignora parzialmente il padding di
* `classiInput` (soprattutto su Android), risultando più alto o più basso
* degli input accanto: `appearance-none` lo riporta a una scatola CSS
* normale, la freccia va poi ridisegnata a mano perché sparisce con lui.
*/
export function Select(props: ComponentPropsWithoutRef<"select">) {
return (
<span className="relative block">
<select {...props} className={cn(classiInput, "appearance-none pr-8", props.className)} />
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
const meta = statoMeta[stato];
return (
+1 -1
View File
@@ -37,7 +37,7 @@ export function Reveal({
{...(style ? { style } : {})}
initial={ridotto ? { opacity: 0 } : { opacity: 0, y: 10 }}
whileInView={ridotto ? { opacity: 1 } : { opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.15, margin: "0px 0px -10% 0px" }}
viewport={{ once: true, amount: "some", margin: "0px 0px -10% 0px" }}
transition={ridotto ? { duration: 0.2, delay: ritardo } : { ...molla.ui, delay: ritardo }}
>
{children}
-41
View File
@@ -1,41 +0,0 @@
// This file is auto-generated by Lovable. Do not modify it.
import { createLovableAuth } from "@lovable.dev/cloud-auth-js";
import { supabase } from "../supabase/client";
const lovableAuth = createLovableAuth();
type SignInOptions = {
redirect_uri?: string;
extraParams?: Record<string, string>;
};
export const lovable = {
auth: {
signInWithOAuth: async (
provider: "google" | "apple" | "microsoft" | "lovable",
opts?: SignInOptions,
) => {
const result = await lovableAuth.signInWithOAuth(provider, {
redirect_uri: opts?.redirect_uri ?? window.location.origin,
extraParams: {
...opts?.extraParams,
},
});
if (result.redirected) {
return result;
}
if (result.error) {
return result;
}
try {
await supabase.auth.setSession(result.tokens);
} catch (e) {
return { error: e instanceof Error ? e : new Error(String(e)) };
}
return result;
},
},
};
+2 -2
View File
@@ -637,7 +637,7 @@ export type Database = {
};
};
Enums: {
app_role: "admin" | "user";
app_role: "admin" | "user" | "allenatore";
};
CompositeTypes: {
[_ in never]: never;
@@ -762,7 +762,7 @@ export const Constants = {
},
public: {
Enums: {
app_role: ["admin", "user"],
app_role: ["admin", "user", "allenatore"],
},
},
} as const;
+62
View File
@@ -0,0 +1,62 @@
/**
* Controllo di accesso per le route in `src/routes/api/public/` che inviano notifiche
* a tutta la squadra (DD-024).
*
* Quelle route usano la service role e saltano la RLS: senza un controllo qui, chiunque
* conosca l'URL può far suonare i telefoni di tutti. L'id di un evento non è un segreto —
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale.
*
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
* verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
*/
/** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */
function tokenDaRichiesta(request: Request): string | null {
const intestazione = request.headers.get("authorization");
if (!intestazione?.startsWith("Bearer ")) return null;
const token = intestazione.slice("Bearer ".length).trim();
// Un JWT ha tre segmenti non vuoti: scartarlo qui evita una chiamata di rete per ogni rumore.
const segmenti = token.split(".");
return segmenti.length === 3 && segmenti.every(Boolean) ? token : null;
}
/**
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
* vale il token, `403` se il token è buono ma il ruolo no.
*/
async function richiediRuolo(
request: Request,
ruoli: Array<"admin" | "allenatore">,
rifiuto: string,
): Promise<Response | null> {
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: utente, error } = await supabaseAdmin.auth.getUser(token);
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: righe } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.in("role", ruoli);
if (!righe?.length) return new Response(rifiuto, { status: 403 });
return null;
}
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
export function richiediAdmin(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
}
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
}
+11
View File
@@ -47,6 +47,17 @@ export function useSessione() {
};
}
/**
* Intestazioni con il token della sessione, per le route server che verificano il ruolo
* (DD-024). Letta al momento della chiamata e non da uno stato React, così non si spedisce
* un token già scaduto.
*/
export async function intestazioniAutenticate(): Promise<Record<string, string>> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
export async function accediConGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: "google",
+6 -2
View File
@@ -29,9 +29,13 @@ export function useAvatarEsiste(id: string | undefined) {
});
}
export function useInvalidaAvatarEsiste() {
/**
* Dopo un caricamento o una rimozione lo stato è noto: si scrive in cache invece di
* rileggere l'elenco del bucket (una richiesta in meno per ogni cambio foto).
*/
export function useImpostaAvatarEsiste() {
const qc = useQueryClient();
return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) });
return (id: string, esiste: boolean) => qc.setQueryData(chiaveEsiste(id), esiste);
}
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
+11 -3
View File
@@ -58,6 +58,12 @@ export type BadgeDef = {
notificaPush?: string;
};
/**
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
*/
export const VOTI_MINIMI_PAGELLA = 5;
export const badgeDefs: BadgeDef[] = [
{
id: "mvp",
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
unita: "di media voto",
icon: ClipboardCheck,
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
valore: (g) => g.mediaVoto,
valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
},
{
id: "palloni",
@@ -130,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [
icon: Ghost,
segreto: true,
soglie: { bronzo: 1, argento: 1, oro: 1 },
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
// Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media
// non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto.
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
celebrazione: "Nei momenti caldi ci sei sempre.",
},
{
@@ -178,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [
id: "s-cacche",
nome: "Trono di ferro",
descrizione:
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
"Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂",
unita: "partite da record",
icon: Toilet,
segreto: true,
+59 -4
View File
@@ -1,15 +1,70 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { oggiISO } from "./palloni-core";
/**
* Avviso «sondaggio pre-partita aperto» (push, centro notifiche ed email, DD-038): stesso testo su
* tutti e tre i canali.
*/
export function avvisoSondaggio(partita: { titolo: string; ora: string }): {
titolo: string;
testo: string;
} {
return {
titolo: "💩 Sondaggio pre-partita aperto",
testo: `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`,
};
}
/**
* Chi riceve l'avviso in-app e per email: i giocatori attivi, mai gli allenatori, che il sondaggio
* non riguarda (DD-034). Non dipende dai dispositivi iscritti alla push: l'avviso in-app e la mail
* arrivano anche a chi non ha attivato le notifiche.
*/
export function destinatariSondaggio(
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
): string[] {
return squadra.filter(inRosa).map((g) => g.id);
}
/** Ora di apertura del sondaggio, il giorno stesso della partita. */
export const ORA_APERTURA_SONDAGGIO = 8;
/** Il sondaggio apre alle 8:00 del giorno della partita e da lì resta aperto. */
export function sondaggioAperto(dataEvento: string, adesso = new Date()): boolean {
/** Istante del fischio d'inizio: `oraEvento` mancante equivale a fine giornata (non chiude mai prima). */
function inizioPartita(dataEvento: string, oraEvento: string): number {
return new Date(`${dataEvento}T${oraEvento || "23:59"}:00`).getTime();
}
/** Il sondaggio apre alle 8:00 del giorno della partita e chiude al fischio d'inizio. */
export function sondaggioAperto(
dataEvento: string,
oraEvento: string,
adesso = new Date(),
): boolean {
if (dataEvento !== oggiISO(adesso)) return false;
const apertura = new Date(`${dataEvento}T00:00:00`);
apertura.setHours(ORA_APERTURA_SONDAGGIO, 0, 0, 0);
return (
adesso.getTime() >= apertura.getTime() &&
adesso.getTime() < inizioPartita(dataEvento, oraEvento)
);
}
/**
* True se il fischio d'inizio è già passato (oggi o in un giorno precedente): serve a
* distinguere, nel messaggio mostrato quando il sondaggio è chiuso, "non ancora aperto"
* da "già chiuso" — altrimenti dopo la partita si continuerebbe a dire "apre alle 8:00".
*/
export function sondaggioTerminato(
dataEvento: string,
oraEvento: string,
adesso = new Date(),
): boolean {
const oggi = oggiISO(adesso);
if (dataEvento !== oggi) return dataEvento < oggi;
return adesso.getHours() >= ORA_APERTURA_SONDAGGIO;
if (dataEvento < oggi) return true;
if (dataEvento > oggi) return false;
return adesso.getTime() >= inizioPartita(dataEvento, oraEvento);
}
/** Sondaggio goliardico pre-partita: quante cacche prima del match di campionato. */
+63
View File
@@ -0,0 +1,63 @@
import { useState } from "react";
/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */
export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"];
export const mesiIT = [
"Gennaio",
"Febbraio",
"Marzo",
"Aprile",
"Maggio",
"Giugno",
"Luglio",
"Agosto",
"Settembre",
"Ottobre",
"Novembre",
"Dicembre",
] as const;
export function pad2(n: number) {
return String(n).padStart(2, "0");
}
/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */
export function giorniDelMese(anno: number, mese: number) {
const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate();
const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay();
const offsetLunedi = (primoGiorno + 6) % 7;
return { giorni, offsetLunedi };
}
/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */
export function useMeseNav(initial?: { anno: number; mese: number }) {
const oggi = new Date();
const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear());
const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth());
// Serve a far entrare e uscire la griglia dallo stesso lato del gesto:
// se un mese esce a sinistra, il precedente deve rientrare da sinistra.
const [direzione, setDirezione] = useState(0);
const precedente = () => {
setDirezione(-1);
if (mese === 0) {
setMese(11);
setAnno((a) => a - 1);
} else {
setMese((m) => m - 1);
}
};
const successivo = () => {
setDirezione(1);
if (mese === 11) {
setMese(0);
setAnno((a) => a + 1);
} else {
setMese((m) => m + 1);
}
};
return { anno, mese, direzione, precedente, successivo };
}
+17 -9
View File
@@ -28,6 +28,8 @@ export type Giocatore = {
nascita: string;
presenze: number;
totaliEventi: number;
/** Solo partite (non allenamenti) a cui era presente o in ritardo. */
partiteGiocate: number;
streak: number;
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
serieAllenamenti: number;
@@ -37,8 +39,12 @@ export type Giocatore = {
mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */
mediaVoto: number;
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
votiPagella: number;
/** Quante volte ha portato i palloni. */
palloni: number;
/** Volte consecutive (fino a oggi) in cui ha portato i palloni. */
seriePalloni: number;
/** Partite di campionato con almeno 3 cacche dichiarate. */
cacche: number;
/** Media di cacche dichiarate per partita. */
@@ -73,7 +79,8 @@ const rosaCSI: Rosa[] = [
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
];
function inizialiDa(nome: string) {
/** Iniziali da un nome completo (max 2 lettere), es. per il fallback di `Avatar`. */
export function inizialiDa(nome: string) {
return nome
.split(" ")
.map((p) => p[0] ?? "")
@@ -98,28 +105,23 @@ export const giocatori: Giocatore[] = rosaCSI
iniziali: inizialiDa(r.nome),
presenze: 0,
totaliEventi: 0,
partiteGiocate: 0,
streak: 0,
serieAllenamenti: 0,
seriePartite: 0,
serieConferme: 0,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
infortuni: 0,
ritardi: 0,
palloni: 0,
seriePalloni: 0,
cacche: 0,
cacchePartita: 0,
}))
.sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it"));
/**
* Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna
* (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota.
*/
export const nascitaPerId: Record<string, string> = Object.fromEntries(
giocatori.map((g) => [g.id, g.nascita]),
);
export type RigaClassifica = {
pos: number;
squadra: string;
@@ -131,6 +133,12 @@ export type RigaClassifica = {
punti: number;
};
/** Data numerica `GG/MM/AAAA`, come scritta dai testi delle notifiche (stessa del database). */
export function formatDataNumerica(iso: string): string {
const [anno, mese, giorno] = iso.split("-");
return `${giorno}/${mese}/${anno}`;
}
export function formatData(iso: string) {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("it-IT", { weekday: "short", day: "2-digit", month: "long" });
+262
View File
@@ -10,21 +10,75 @@ import type { RigaClassifica } from "./crapp-data";
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
*/
export const CSI_COPPA_PROJECT_ID = 848;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
/**
* Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non
* espone il nome della stagione (né nella classifica né nel calendario della squadra):
* si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di
* settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono.
*/
export function stagioneDi(dataIso: string): string {
const anno = Number(dataIso.slice(0, 4));
const mese = Number(dataIso.slice(5, 7));
const inizio = mese >= 8 ? anno : anno - 1;
return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`;
}
/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */
export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null {
const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d));
if (date.length === 0) return null;
return stagioneDi(date.reduce((a, b) => (a < b ? a : b)));
}
/**
* Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in
* locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato.
*/
export function raggruppaPerStagione<T extends { data: string }>(
match: readonly T[],
): Array<{ stagione: string; match: T[] }> {
const gruppi: Array<{ stagione: string; match: T[] }> = [];
for (const m of match) {
const stagione = stagioneDi(m.data);
const gruppo = gruppi.find((g) => g.stagione === stagione);
if (gruppo) gruppo.match.push(m);
else gruppi.push({ stagione, match: [m] });
}
return gruppi;
}
export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
export const urlPartite = (teamId = CSI_TEAM_ID) =>
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
/** Info generali della gara (giornata, pubblico ammesso). */
export const urlPartitaInfo = (matchId: string) =>
`${CSI_BASE}/components/match-main.php?match_id=${matchId}`;
/** Formazioni titolari/panchina/staff di entrambe le squadre. */
export const urlPartitaFormazioni = (matchId: string) =>
`${CSI_BASE}/components/match-players.php?match_id=${matchId}`;
/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */
export const urlPartitaPrecedenti = (matchId: string) =>
`${CSI_BASE}/components/match-stats.php?match_id=${matchId}`;
export type PartitaCsi = {
id: string;
data: string;
ora: string;
avversario: string;
/** URL del logo avversario sul portale CSI, vuoto se non presente. */
logoAvversario: string;
casa: boolean;
/** null finché la gara non è stata giocata. */
setNostri: number | null;
@@ -32,13 +86,60 @@ export type PartitaCsi = {
parziali: Array<[number, number]>;
campo: string;
competizione: string;
/** Es. "Girone B". */
girone: string;
/** Es. "5/XEB". */
numeroGara: string;
arbitro: string;
/** Referto ufficiale su livescore.csibologna.it. */
link: string;
};
export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string };
export type StaffFormazione = { nome: string; ruolo: string };
export type FormazioneSquadra = {
squadra: string;
titolari: GiocatoreFormazione[];
panchina: GiocatoreFormazione[];
staff: StaffFormazione[];
};
export type PrecedentiCsi = {
totale: number;
vinteNoi: number;
vinteAvversario: number;
casaNoi: number;
casaAvversario: number;
fuoriNoi: number;
fuoriAvversario: number;
/** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */
probabilitaNoi: number | null;
probabilitaAvversario: number | null;
};
export type DettaglioPartitaCsi = {
/** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */
giornata: string;
/**
* Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome
* della palestra o altro — non ha un formato fisso, va mostrato così com'è. Vuoto se assente.
*/
nota: string;
/** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */
formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null;
/** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */
precedenti: PrecedentiCsi | null;
};
export type DatiCsi = {
classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[];
partite: PartitaCsi[];
girone: string;
aggiornato: string;
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
formatoSospetto: boolean;
};
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
@@ -107,11 +208,17 @@ type EventoCsi = {
id?: number | string;
start?: string;
team1?: string;
team1_logo?: string;
team2?: string;
team2_logo?: string;
result?: string;
partials?: string;
field?: string;
project?: string;
group?: string;
match_number?: string;
referees?: string;
link?: string;
};
function punteggio(result: string | undefined): [number, number] | null {
@@ -146,12 +253,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
data: dataIso,
ora: (oraIso ?? "").slice(0, 5),
avversario: casa ? team2 : team1,
logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "",
casa,
setNostri: set ? (casa ? set[0] : set[1]) : null,
setLoro: set ? (casa ? set[1] : set[0]) : null,
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
campo: testo(evento.field ?? ""),
competizione: testo(evento.project ?? ""),
girone: testo(evento.group ?? ""),
numeroGara: testo(evento.match_number ?? ""),
arbitro: testo(evento.referees ?? ""),
link: evento.link?.trim() ?? "",
});
}
return partite.sort((a, b) => b.data.localeCompare(a.data));
@@ -162,15 +274,165 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
}
/**
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
* silenzioso — vedi "Limiti noti" in docs/modules/collegamento-csi.md.
*/
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
if (!Array.isArray(eventiGrezzi)) return true;
return eventiGrezzi.length > 0 && partite.length === 0;
}
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
export function matchDaPartitaCsi(p: PartitaCsi) {
return {
id: p.id,
data: p.data,
avversario: p.avversario,
logoAvversario: p.logoAvversario,
casa: p.casa,
setNostri: p.setNostri ?? 0,
setLoro: p.setLoro ?? 0,
parziali: p.parziali,
campo: p.campo,
girone: p.girone,
numeroGara: p.numeroGara,
arbitro: p.arbitro,
link: p.link,
};
}
/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */
export function parseInfoPartita(html: string): { giornata: string; nota: string } {
const giornataM = /(\d+)\s*<sup>a<\/sup>\s*Giornata/.exec(html);
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
return {
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
nota: notaM ? testo(notaM[1] ?? "") : "",
};
}
/**
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, in
* ordine titolari → divisore "A DISPOSIZIONE" → panchina → divisore "STAFF" → staff. I
* membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia.
*/
function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null {
const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco);
const squadra = nomeM ? testo(nomeM[1] ?? "") : "";
if (!squadra) return null;
const titolari: GiocatoreFormazione[] = [];
const panchina: GiocatoreFormazione[] = [];
const staff: StaffFormazione[] = [];
let sezione: "titolari" | "panchina" | "staff" = "titolari";
for (const voce of blocco.match(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
if (/A DISPOSIZIONE/.test(voce)) {
sezione = "panchina";
continue;
}
if (/STAFF/.test(voce)) {
sezione = "staff";
continue;
}
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
if (!nomePersonaM) continue;
const nome = testo(nomePersonaM[1] ?? "");
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
if (sezione === "staff") {
staff.push({ nome, ruolo });
continue;
}
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
(sezione === "titolari" ? titolari : panchina).push(giocatore);
}
return { squadra, titolari, panchina, staff };
}
/**
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
* `<!-- SQUADRA OSPITE -->`; qui si riconosce la nostra tramite `isNostraSquadra()`
* invece di assumere un ordine fisso.
*/
export function parseFormazioni(
html: string,
): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null {
const idx = html.indexOf("SQUADRA OSPITE");
if (idx === -1) return null;
const primo = parseBloccoSquadraFormazione(html.slice(0, idx));
const secondo = parseBloccoSquadraFormazione(html.slice(idx));
if (!primo || !secondo) return null;
const noi = isNostraSquadra(primo.squadra)
? primo
: isNostraSquadra(secondo.squadra)
? secondo
: null;
if (!noi) return null;
return { noi, avversario: noi === primo ? secondo : primo };
}
/**
* Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di
* riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare)
* riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si
* riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in
* `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe
* vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente.
*/
export function parsePrecedenti(html: string): PrecedentiCsi | null {
const idxCasa = html.indexOf("Squadra casa");
const idxOspite = html.indexOf("Squadra ospite");
if (idxCasa === -1 || idxOspite === -1) return null;
const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxCasa, idxOspite),
);
const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
html.slice(idxOspite),
);
const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : "";
const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : "";
const noiECasa = isNostraSquadra(nomeCasa);
if (!noiECasa && !isNostraSquadra(nomeOspite)) return null;
const totaleM = /Precedenti:<\/span>\s*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
const vittorieM =
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const inCasaM =
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(
html,
);
// Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso)
// segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la
// classe CSS per capire quale percentuale è "nostra".
const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) =>
Number(m[1]),
);
const [probCasa, probOspite] = percentuali;
// I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara:
// `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`.
const casaIndice = noiECasa ? 1 : 2;
const ospiteIndice = noiECasa ? 2 : 1;
const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0);
return {
totale: totaleM ? Number(totaleM[1]) : 0,
vinteNoi: numero(vittorieM, casaIndice),
vinteAvversario: numero(vittorieM, ospiteIndice),
casaNoi: numero(inCasaM, casaIndice),
casaAvversario: numero(inCasaM, ospiteIndice),
fuoriNoi: numero(fuoriM, casaIndice),
fuoriAvversario: numero(fuoriM, ospiteIndice),
probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null,
probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import type { DettaglioPartitaCsi } from "./csi-core";
/**
* Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in
* più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il
* dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md.
*/
export function useCsiPartita(matchId: string | undefined) {
return useQuery<DettaglioPartitaCsi>({
queryKey: ["csi-partita", matchId],
queryFn: async () => {
const res = await fetch(`/api/public/csi-partita/${matchId}`);
if (!res.ok) throw new Error("csi-partita non disponibile");
return res.json();
},
enabled: !!matchId,
staleTime: 6 * 60 * 60_000,
retry: 1,
});
}
+13 -3
View File
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
id: nuovoIdEvento(),
tipo: "allenamento",
titolo: "",
luogo: "Palestra Comunale",
luogo: "",
data: new Date().toISOString().slice(0, 10),
ora: "20:30",
note: "",
@@ -162,8 +162,18 @@ export function useEliminaEvento() {
});
}
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
/**
* Compleanni della rosa, come eventi di calendario dell'anno indicato.
*
* Prende solo i tre campi che usa (non l'intero `Giocatore`): il Calendario li
* legge da un'anagrafica leggera per non tirarsi dietro tutte le statistiche
* (MVP, pagelle, cacche, palloni, infortuni) di `useRosa` solo per le date di
* nascita.
*/
export function compleanniEventi(
rosa: Array<Pick<Giocatore, "id" | "nome" | "nascita">>,
anno = new Date().getFullYear(),
): Evento[] {
return rosa
.filter((g) => g.nascita)
.map((g) => {
+106 -25
View File
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
* la migrazione non è completa (DD-016 regola 1).
*/
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
export type TipoMembro = "giocatore" | "allenatore";
export type GiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
numero: number;
ruolo: string;
authUserId: string | null;
@@ -18,19 +22,24 @@ export type GiocatoreSquadra = {
email: string | null;
numeroTessera: string | null;
dataTessera: string | null;
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
nascita: string | null;
tipo: TipoMembro;
};
export type RigaGiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
numero: number | null;
ruolo: string;
auth_user_id: string | null;
attivo: boolean;
email: string | null;
numero_tessera: string | null;
data_tessera: string | null;
nascita: string | null;
tipo: TipoMembro;
};
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
@@ -50,6 +59,8 @@ export function rosaFallback(): GiocatoreSquadra[] {
email: null,
numeroTessera: null,
dataTessera: null,
nascita: g.nascita,
tipo: "giocatore" as const,
}));
}
@@ -57,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
return `${g.nome} ${g.cognome}`.trim();
}
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
return g?.tipo === "allenatore";
}
/**
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
*/
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
return g.attivo && g.tipo === "giocatore";
}
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
}
/** Lo slot già collegato a questo account, se esiste. */
export function slotDi(
righe: GiocatoreSquadra[],
@@ -77,7 +106,7 @@ export function slotPerEmail(
}
export const COLONNE_SQUADRA =
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera";
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
@@ -85,13 +114,15 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
id: r.id,
nome: r.nome,
cognome: r.cognome,
numero: r.numero,
numero: r.numero ?? 0,
ruolo: r.ruolo,
authUserId: r.auth_user_id,
attivo: r.attivo,
email: r.email,
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
nascita: r.nascita,
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
};
}
@@ -115,7 +146,10 @@ export function useGiocatoriSquadra() {
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
export type DatiSquadra = Pick<
GiocatoreSquadra,
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
>;
/**
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
@@ -125,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
if (dati.tipo === "allenatore") {
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
return null;
}
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
return "Il numero di maglia deve essere maggiore di zero.";
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
@@ -147,7 +186,19 @@ export function numeroGiaUsato(
giocatoreId: string,
numero: number,
): boolean {
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
}
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
function rigaDatiSquadra(dati: DatiSquadra) {
const allenatore = dati.tipo === "allenatore";
return {
nome: dati.nome.trim(),
cognome: dati.cognome.trim(),
numero: allenatore ? null : dati.numero,
ruolo: allenatore ? "" : dati.ruolo.trim(),
email: dati.email?.trim() || null,
};
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
@@ -155,19 +206,13 @@ export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
const dati = {
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const riga = rigaDatiSquadra(input.dati);
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.update(riga)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
@@ -219,19 +264,55 @@ export function useSalvaTesseramento() {
export function useAggiungiGiocatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }) => {
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({
id: input.id,
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
});
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error;
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
return {
...riga,
numero: riga.numero ?? 0,
authUserId: null,
attivo: true,
numeroTessera: null,
dataTessera: null,
nascita: null,
};
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY });
// Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query
// (`.order("cognome").order("nome")`).
onSuccess: (nuovo) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
[...(prec ?? []), nuovo].sort(
(a, b) => a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome),
),
);
},
});
}
/**
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
*/
export function useSalvaNomeAllenatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
+45 -13
View File
@@ -1,13 +1,31 @@
import { useMemo } from "react";
import type { Giocatore } from "./crapp-data";
import type { Evento } from "./eventi";
import { useEventi } from "./eventi";
import { dataOggi } from "./scout-live";
import { useRispostePresenze, type MappaPresenze } from "./presenze";
/** giocatoreId -> numero di eventi (allenamenti + partite) con stato "infortunato". */
export type ContoInfortuni = Record<string, number>;
function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
/**
* Un giocatore può segnarsi infortunato o in ritardo anche su un evento futuro
* (l'UI lo permette finché l'evento non è passato): finché quell'evento non è
* avvenuto davvero non deve contare per i badge, altrimenti si sbloccherebbero
* in anticipo. Un evento non più in `eventi` (es. cancellato) non viene contato:
* non potendo verificarne la data, si esclude per prudenza.
*/
function contaStato(
presenze: MappaPresenze,
stato: string,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
const dataPerEvento = new Map(eventi.map((e) => [e.id, e.data]));
const out: ContoInfortuni = {};
for (const eventoId of Object.keys(presenze)) {
const dataEvento = dataPerEvento.get(eventoId);
if (dataEvento === undefined || dataEvento >= oggi) continue;
const evento = presenze[eventoId] ?? {};
for (const giocatoreId of Object.keys(evento)) {
if (evento[giocatoreId] === stato) out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
@@ -16,14 +34,22 @@ function contaStato(presenze: MappaPresenze, stato: string): ContoInfortuni {
return out;
}
/** Conta gli infortuni dalla mappa presenze già in cache: ogni evento vale una volta sola. */
export function contaInfortuni(presenze: MappaPresenze): ContoInfortuni {
return contaStato(presenze, "infortunato");
/** Conta gli infortuni dalla mappa presenze già in cache: ogni evento passato vale una volta sola. */
export function contaInfortuni(
presenze: MappaPresenze,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
return contaStato(presenze, "infortunato", eventi, oggi);
}
/** Conta i ritardi dalla stessa mappa presenze: ogni evento vale una volta sola. */
export function contaRitardi(presenze: MappaPresenze): ContoInfortuni {
return contaStato(presenze, "ritardo");
/** Conta i ritardi dalla stessa mappa presenze: ogni evento passato vale una volta sola. */
export function contaRitardi(
presenze: MappaPresenze,
eventi: Evento[],
oggi: string = dataOggi(),
): ContoInfortuni {
return contaStato(presenze, "ritardo", eventi, oggi);
}
export function conInfortuni<T extends Giocatore>(
@@ -38,23 +64,29 @@ export function conInfortuni<T extends Giocatore>(
};
}
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
export function useInfortuni(): ContoInfortuni {
const { presenze } = useRispostePresenze();
return useMemo(() => contaInfortuni(presenze), [presenze]);
const { eventi } = useEventi();
return useMemo(() => contaInfortuni(presenze, eventi), [presenze, eventi]);
}
/** Nessuna query aggiuntiva: riusa la cache delle risposte presenze. */
/** Nessuna query aggiuntiva: riusa le cache di risposte presenze ed eventi. */
export function useRitardi(): ContoInfortuni {
const { presenze } = useRispostePresenze();
return useMemo(() => contaRitardi(presenze), [presenze]);
const { eventi } = useEventi();
return useMemo(() => contaRitardi(presenze, eventi), [presenze, eventi]);
}
/** Un solo hook per entrambi i conteggi: evita hook extra nei componenti. */
export function useInfortuniERitardi(): { infortuni: ContoInfortuni; ritardi: ContoInfortuni } {
const { presenze } = useRispostePresenze();
const { eventi } = useEventi();
return useMemo(
() => ({ infortuni: contaInfortuni(presenze), ritardi: contaRitardi(presenze) }),
[presenze],
() => ({
infortuni: contaInfortuni(presenze, eventi),
ritardi: contaRitardi(presenze, eventi),
}),
[presenze, eventi],
);
}
-58
View File
@@ -1,58 +0,0 @@
type LovableErrorOptions = {
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
handled?: boolean;
severity?: "error" | "warning" | "info";
};
type LovableEvents = {
captureException?: (
error: unknown,
context?: Record<string, unknown>,
options?: LovableErrorOptions,
) => void;
};
declare global {
interface Window {
__lovableEvents?: LovableEvents;
__lovableReportRuntimeError?: (payload: {
message: string;
stack?: string;
filename?: string;
}) => void;
}
}
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
if (typeof window === "undefined") return;
window.__lovableEvents?.captureException?.(
error,
{
source: "react_error_boundary",
route: window.location.pathname,
...context,
},
{
mechanism: "react_error_boundary",
handled: false,
severity: "error",
},
);
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
// which is present only inside the editor preview.
// Loaders and server fns commonly throw a raw Response; String(it) is the
// opaque "[object Response]", so pull out the status and URL instead.
const message =
error instanceof Response
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
window.__lovableReportRuntimeError?.({
message,
...(stack !== undefined && { stack }),
filename: window.location.pathname,
});
}
+31 -4
View File
@@ -10,6 +10,16 @@ export type VotoMvp = {
const CHIAVE = ["mvp-voti"] as const;
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
export const ORE_ATTESA_MVP = 2;
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false;
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
}
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
export function useVotiMvp() {
@@ -50,6 +60,9 @@ export function useVotaMvp() {
export type ConteggioMvp = { id: string; nome: string; voti: number };
/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */
export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>();
@@ -73,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
const out: Record<string, string> = {};
for (const [matchId] of perMatch) {
const top = conteggioPartita(voti, matchId);
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
}
}
@@ -85,13 +103,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
}
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
/**
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
* restituisce una mappa vuota.
*/
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id));
for (const matchId of matchIds) {
const top = conteggioPartita(voti, matchId);
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
+37
View File
@@ -0,0 +1,37 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
async function fetchNotificheAttive(): Promise<Set<string>> {
const res = await fetch("/api/public/notifiche-attive", {
headers: await intestazioniAutenticate(),
});
if (!res.ok) throw new Error("Impossibile leggere le notifiche attive");
const { giocatoreIds } = (await res.json()) as { giocatoreIds: string[] };
return new Set(giocatoreIds);
}
/** Insieme degli id giocatore con almeno un dispositivo iscritto alle notifiche push. */
export function useNotificheAttive() {
return useQuery({
queryKey: NOTIFICHE_ATTIVE_KEY,
queryFn: fetchNotificheAttive,
staleTime: 5 * 60_000,
});
}
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
export function useInviaNotifica() {
return useMutation({
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
const res = await fetch("/api/public/notifica-personalizzata", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ messaggio, giocatoreId }),
});
if (!res.ok) throw new Error("Impossibile inviare la notifica");
return (await res.json()) as { inviate: number; destinatari: number };
},
});
}
+4
View File
@@ -0,0 +1,4 @@
/** Id giocatore distinti tra le righe di `push_subscriptions` (una riga per dispositivo). */
export function idsConNotificheAttive(righe: Array<{ giocatore_id: string }>): string[] {
return [...new Set(righe.map((r) => r.giocatore_id))];
}
+153
View File
@@ -0,0 +1,153 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { useGiocatoreId } from "./user-store";
/**
* Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non
* lette. Cinque sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un
* messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il
* turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in
* parallelo alla push esistente). Qui si legge e si segna come letto, con
* `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi
* client-nuove-tabelle.ts).
*/
export type TipoNotifica =
| "admin"
| "evento_promemoria_24h"
| "evento_promemoria_3h"
| "turno_palloni"
| "turno_palloni_12h"
| "turno_palloni_6h"
| "turno_palloni_3h"
| "turno_palloni_revocato"
| "sollecita_presenze"
| "sollecita_presenze_24h"
| "sollecita_presenze_12h"
| "sollecita_presenze_6h"
| "sondaggio_cacche";
export type NotificaUtente = {
id: string;
tipo: TipoNotifica;
titolo: string;
corpo: string;
eventoId: string | null;
letta: boolean;
creataIl: string;
};
export type RigaNotifica = {
id: string;
tipo: TipoNotifica;
titolo: string;
corpo: string;
evento_id: string | null;
letta: boolean;
creato_il: string;
};
const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il";
/** Conversione riga database -> modello applicativo. */
export function daRiga(r: RigaNotifica): NotificaUtente {
return {
id: r.id,
tipo: r.tipo,
titolo: r.titolo,
corpo: r.corpo,
eventoId: r.evento_id,
letta: r.letta,
creataIl: r.creato_il,
};
}
function chiaveNotifiche(giocatoreId: string | null) {
return ["notifiche-utente", giocatoreId] as const;
}
/** Ultime notifiche del giocatore selezionato, più recenti per prime. */
export function useNotificheMie() {
const giocatoreId = useGiocatoreId();
const query = useQuery({
queryKey: chiaveNotifiche(giocatoreId),
queryFn: async (): Promise<NotificaUtente[]> => {
const { data, error } = await supabaseNuoveTabelle
.from("notifiche_utente")
.select(COLONNE)
.eq("giocatore_id", giocatoreId)
.order("creato_il", { ascending: false })
.limit(30);
if (error) throw error;
return ((data ?? []) as RigaNotifica[]).map(daRiga);
},
enabled: !!giocatoreId,
staleTime: 30_000,
refetchInterval: 60_000,
});
const notifiche = query.data ?? [];
return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length };
}
/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */
export function useSegnaLette() {
const giocatoreId = useGiocatoreId();
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!giocatoreId) return;
const { error } = await supabaseNuoveTabelle
.from("notifiche_utente")
.update({ letta: true })
.eq("giocatore_id", giocatoreId)
.eq("letta", false);
if (error) throw error;
},
onSuccess: () => {
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
(prec ?? []).map((n) => ({ ...n, letta: true })),
);
},
});
}
/**
* Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A
* differenza di "segna come letta" qui la riga sparisce anche dal database, non solo
* dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è
* l'unico modo per un giocatore di toglierla di mezzo per sempre.
*/
export function useEliminaNotifica() {
const giocatoreId = useGiocatoreId();
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id);
if (error) throw error;
return id;
},
onSuccess: (id) => {
qc.setQueryData<NotificaUtente[]>(chiaveNotifiche(giocatoreId), (prec) =>
(prec ?? []).filter((n) => n.id !== id),
);
},
});
}
/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */
export function contatoreBadge(nonLette: number): string {
return nonLette > 9 ? "9+" : String(nonLette);
}
/**
* Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette
* resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più
* raggiungibili per eliminarle. Senza notifiche non c'è (`null`).
*/
export function pallinoNotifiche(
totale: number,
nonLette: number,
): { testo: string; daLeggere: boolean } | null {
if (totale === 0) return null;
if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true };
return { testo: contatoreBadge(totale), daLeggere: false };
}
+34 -11
View File
@@ -26,11 +26,32 @@ export type ContestoObiettivi = {
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
const MESE = "2026-08";
/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */
function meseCorrente(oggi: Date): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
function aMese(oggi: Date): string {
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
oggi,
);
const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a";
return `${preposizione} ${nome}`;
}
/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */
function fineMese(oggi: Date): string {
const mese = meseCorrente(oggi);
const anno = Number(mese.slice(0, 4));
const numeroMese = Number(mese.slice(5, 7));
const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate();
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
);
if (delMese.length === 0 || rosaSize === 0) return 0;
const posti = delMese.length * rosaSize;
@@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
export function obiettiviSquadra(
rosa: Giocatore[] = giocatori,
ctx: ContestoObiettivi = contestoVuoto,
oggi: Date = new Date(),
): ObiettivoSquadra[] {
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
const vittorie = ctx.vittorie ?? 0;
const mese = meseCorrente(oggi);
return [
{
id: "o1",
titolo: "90% di presenze ad agosto",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx, rosa.length),
valore: percentualePresenzeMese(ctx, rosa.length, mese),
target: 90,
unita: "%",
scadenza: "2026-08-31",
scadenza: fineMese(oggi),
emoji: "📣",
impatto: "Ogni sì in più alza la media di tutta la squadra.",
},
@@ -79,7 +102,7 @@ export function obiettiviSquadra(
valore: percentualeRisposte(ctx, rosa.length),
target: 90,
unita: "%",
scadenza: "2026-09-30",
scadenza: fineMese(oggi),
emoji: "⚡",
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
},
@@ -147,7 +170,7 @@ export function obiettiviSquadra(
id: "o5",
titolo: "10 vittorie in campionato",
descrizione: "Obiettivo stagionale per il podio",
valore: vittorie,
valore: Math.min(vittorie, 10),
target: 10,
unita: "vittorie",
emoji: "🏆",
@@ -157,7 +180,7 @@ export function obiettiviSquadra(
id: "o6",
titolo: "1 evento di squadra al mese",
descrizione: "Pizzate, cene e uscite fuori dal campo",
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
target: 1,
unita: "eventi",
emoji: "🍕",
@@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
return Math.min(100, Math.round((o.valore / o.target) * 100));
}
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) {
return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => {
const pa = progressoObiettivo(a);
const pb = progressoObiettivo(b);
const ca = pa >= 100 ? 1 : 0;
+15 -1
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { VOTI_MINIMI_PAGELLA } from "./badges";
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
export type VotoPagella = {
@@ -60,7 +61,11 @@ function arrotonda(n: number) {
return Math.round(n * 10) / 10;
}
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
/**
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
* di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti
* non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback.
*/
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
const somma: Record<string, { tot: number; n: number }> = {};
for (const v of voti) {
@@ -90,6 +95,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string
return out;
}
/**
* Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto
* `VOTI_MINIMI_PAGELLA` voti ricevuti, `—` invece della media grezza — stessa soglia del
* badge Pagellone, non applicata invece dalle StatTile di profilo e squadra.
*/
export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" {
return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—";
}
/** Media pagelle di tutta la squadra su tutte le partite. */
export function mediaSquadra(voti: VotoPagella[]) {
if (voti.length === 0) return 0;
+96 -38
View File
@@ -1,17 +1,26 @@
import { formatData } from "./crapp-data";
import { formatDataNumerica } from "./crapp-data";
import type { Evento } from "./eventi";
import { dataOggi } from "./scout-live";
import { aggiornaSerie } from "./serie";
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
export type CandidatoTurno = { id: string; nome: string };
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
/**
* Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data e, a parità,
* di ora. Lo stesso ordine lo usa il database per i job automatici (M26, DD-040).
*/
export function eventiPalloni(eventi: Evento[]): Evento[] {
return eventi
.filter((e) => e.tipo !== "compleanno")
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
.slice()
.sort((a, b) => a.data.localeCompare(b.data));
.sort((a, b) => {
if (a.data !== b.data) return a.data < b.data ? -1 : 1;
if (a.ora !== b.ora) return a.ora < b.ora ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
/**
@@ -19,7 +28,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
* meno volte (a parità, chi non lo fa da più tempo).
*
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
* non conferma un incaricato a mano. Su partite ed eventi extra la rotazione resta.
* non conferma un incaricato a mano. Sulle partite la rotazione resta.
*/
export function completaTurni(
turni: Record<string, string>,
@@ -60,13 +69,43 @@ export function completaTurni(
return risultato;
}
/** Quante volte ciascun giocatore è incaricato dei palloni. */
export function conteggioTurni(turni: Record<string, string>): Record<string, number> {
/**
* Quante volte ciascun giocatore è incaricato dei palloni, solo per eventi già passati:
* un turno assegnato in anticipo per un allenamento futuro non è ancora "portato", quindi
* non deve contare finché quell'allenamento non è terminato (stesso criterio `e.data < oggi`
* usato per le presenze, così la conta non cambia da sola col passare della giornata).
*/
export function conteggioTurni(
turni: Record<string, string>,
eventi: Evento[],
oggi: string = dataOggi(),
): Record<string, number> {
const passati = new Set(eventi.filter((e) => e.data < oggi).map((e) => e.id));
const out: Record<string, number> = {};
for (const id of Object.values(turni)) out[id] = (out[id] ?? 0) + 1;
for (const [eventoId, giocatoreId] of Object.entries(turni)) {
if (!passati.has(eventoId)) continue;
out[giocatoreId] = (out[giocatoreId] ?? 0) + 1;
}
return out;
}
/**
* Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già
* passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza
* turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato
* i palloni quella volta.
*/
export function serieConsecutivaPalloni(
giocatoreId: string,
turni: Record<string, string>,
eventi: Evento[],
oggi: string = dataOggi(),
): number {
return eventiPalloni(eventi)
.filter((e) => e.data < oggi && turni[e.id])
.reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0);
}
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
return eventiPalloni(eventi).filter((e) => e.data === isoData);
}
@@ -84,10 +123,9 @@ export function eventoSuccessivo(eventi: Evento[], eventoId: string): Evento | u
return i >= 0 ? lista[i + 1] : undefined;
}
/** Alias di `dataOggi()`, nel fuso di Roma: qui per non toccare gli import esistenti. */
export function oggiISO(d = new Date()): string {
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${mm}-${dd}`;
return dataOggi(d);
}
/** Chi deve ricevere l'avviso push, oggi: chi porta i palloni e chi li riprende. */
@@ -107,35 +145,55 @@ export function destinatariPromemoriaPalloni(
return [...destinatari];
}
/** Testo del push per un giocatore: priorità a "riporta oggi", poi "tocca a te", poi generico. */
export function messaggioPalloniOggi(
/**
* Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025, DD-040).
*
* Sono gli stessi testi dei job automatici del database (migration M26): all'incaricato
* «incarico assegnato», a chi aveva i palloni all'evento precedente (se è un'altra persona)
* «riconsegna». Se per l'evento non c'è un incaricato non parte nulla. Il testo viaggia cifrato
* dentro la push, quindi il service worker lo mostra senza rete.
*/
export function avvisiPalloniEvento(
turni: Record<string, string>,
eventi: Evento[],
oggi: string,
mioId: string,
nome: string,
): { title: string; body: string } {
for (const evento of eventiDelGiorno(eventi, oggi)) {
const prima = eventoPrecedente(eventi, evento.id);
if (prima && turni[prima.id] === mioId) {
return {
title: "Porta i palloni oggi",
body: `${evento.titolo} · ${evento.ora}. I palloni li hai tu dalla volta scorsa.`,
};
}
if (turni[evento.id] === mioId) {
const dopo = eventoSuccessivo(eventi, evento.id);
return {
title: "Tocca a te prendere i palloni",
body: dopo
? `A fine ${evento.titolo} porta a casa i palloni e riportali il ${formatData(dopo.data)}.`
: `A fine ${evento.titolo} porta a casa i palloni.`,
};
}
eventoId: string,
): Array<{ giocatoreId: string; titolo: string; testo: string }> {
const evento = eventiPalloni(eventi).find((e) => e.id === eventoId);
if (!evento) return [];
const incaricato = turni[evento.id];
if (!incaricato) return [];
const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = [];
const intestazione = [
`Evento: ${evento.titolo}`,
`Data: ${formatDataNumerica(evento.data)}, ore ${evento.ora}`,
];
const dopo = eventoSuccessivo(eventi, evento.id);
avvisi.push({
giocatoreId: incaricato,
titolo: "Turno palloni: incarico assegnato",
testo: [
...intestazione,
"Incarico: custodia dei palloni al termine dell'evento",
...(dopo ? [`Riconsegna: ${formatDataNumerica(dopo.data)}`] : []),
].join("\n"),
});
// Chi li ha presi la volta scorsa deve ricordarsi di riportarli.
const prima = eventoPrecedente(eventi, evento.id);
const precedente = prima ? turni[prima.id] : undefined;
if (precedente && precedente !== incaricato) {
avvisi.push({
giocatoreId: precedente,
titolo: "Turno palloni: riconsegna",
testo: [
...intestazione,
"Incarico: riconsegna dei palloni in custodia dal turno precedente",
].join("\n"),
});
}
return {
title: "CrAPP · Turno palloni",
body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.",
};
return avvisi;
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
export const TURNI_KEY = ["turni-palloni"] as const;
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
const { eventi } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salvati = query.data ?? {};
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
}
+60
View File
@@ -0,0 +1,60 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
import { useSessione } from "./auth";
/**
* Preferenze del singolo account (M22), non dello slot giocatore: oggi solo l'interruttore
* «Email» di Profilo → Opzioni (DD-036). Nessuna riga in `preferenze_utente` vuol dire
* «mail attive»: la riga nasce solo quando il giocatore tocca l'interruttore. Lettura e
* scrittura passano dalla RLS (ognuno vede e scrive solo la propria), senza route API.
*/
export type RigaPreferenze = { email_notifiche: boolean };
/** Assenza di riga = valori di default: le email sono attive. */
export function emailAttive(riga: RigaPreferenze | null | undefined): boolean {
return riga?.email_notifiche ?? true;
}
const chiavePreferenze = (utenteId: string | null) => ["preferenze-utente", utenteId] as const;
/** Stato dell'interruttore «Email» dell'account e mutazione per cambiarlo. */
export function useEmailNotifiche() {
const { utenteId } = useSessione();
const qc = useQueryClient();
const query = useQuery({
queryKey: chiavePreferenze(utenteId),
queryFn: async (): Promise<RigaPreferenze | null> => {
const { data, error } = await supabaseNuoveTabelle
.from("preferenze_utente")
.select("email_notifiche")
.eq("auth_user_id", utenteId)
.maybeSingle();
if (error) throw error;
return (data as RigaPreferenze | null) ?? null;
},
enabled: !!utenteId,
staleTime: 5 * 60_000,
});
const imposta = useMutation({
mutationFn: async (attive: boolean) => {
const { error } = await supabaseNuoveTabelle.from("preferenze_utente").upsert(
{
auth_user_id: utenteId,
email_notifiche: attive,
aggiornata_il: new Date().toISOString(),
},
{ onConflict: "auth_user_id" },
);
if (error) throw error;
return attive;
},
onSuccess: (attive) => {
const riga: RigaPreferenze = { email_notifiche: attive };
qc.setQueryData(chiavePreferenze(utenteId), riga);
},
});
return { attive: emailAttive(query.data), pronta: !query.isPending, imposta };
}
+72
View File
@@ -0,0 +1,72 @@
import type { Evento } from "./eventi";
import type { MappaPresenze } from "./presenze";
/**
* Calcolo puro della percentuale di presenze dell'ultimo mese, separato dagli hook
* (`presenze-mese.ts`) per poterlo testare: le tre regole che decidono il numero —
* finestra di 30 giorni, il ritardo che conta come presenza, `convocati` vuoto uguale
* a tutta la rosa — stanno qui.
*/
export type StatistichePresenza = { presenti: number; totali: number; percentuale: number };
const NESSUNA: StatistichePresenza = { presenti: 0, totali: 0, percentuale: 0 };
/** Chi è arrivato in ritardo era comunque all'allenamento: conta come presente. */
const eraPresente = (stato: string | undefined) => stato === "presente" || stato === "ritardo";
/** Finestra di 30 giorni che si chiude oggi, in date ISO. */
export function finestraMese(oggi: Date = new Date()): { da: string; a: string } {
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
return { da: inizio.toISOString().slice(0, 10), a: oggi.toISOString().slice(0, 10) };
}
/** Partite e allenamenti dentro la finestra: gli altri eventi non contano. */
function eventiDelMese(eventi: Evento[], oggi: Date): Evento[] {
const { da, a } = finestraMese(oggi);
return eventi.filter(
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
);
}
function conPercentuale(presenti: number, totali: number): StatistichePresenza {
return { presenti, totali, percentuale: totali ? Math.round((presenti / totali) * 100) : 0 };
}
/** Presenze dell'ultimo mese di un singolo giocatore. */
export function presenzeUltimoMese(
giocatoreId: string | undefined,
eventi: Evento[],
presenze: MappaPresenze,
oggi: Date = new Date(),
): StatistichePresenza {
if (!giocatoreId) return NESSUNA;
const rilevanti = eventiDelMese(eventi, oggi).filter(
(e) => e.convocati.length === 0 || e.convocati.includes(giocatoreId),
);
const presenti = rilevanti.filter((e) => eraPresente(presenze[e.id]?.[giocatoreId])).length;
return conPercentuale(presenti, rilevanti.length);
}
/**
* Presenze dell'ultimo mese di tutta la rosa, in una mappa per id. Un evento senza
* convocati vale per tutti gli attivi, uno con l'elenco solo per i convocati.
*/
export function presenzeUltimoMeseTutti(
eventi: Evento[],
presenze: MappaPresenze,
idRosa: string[],
oggi: Date = new Date(),
): Record<string, StatistichePresenza> {
const out: Record<string, StatistichePresenza> = {};
for (const e of eventiDelMese(eventi, oggi)) {
for (const id of e.convocati.length > 0 ? e.convocati : idRosa) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1;
if (eraPresente(presenze[e.id]?.[id])) rec.presenti += 1;
}
}
for (const [id, rec] of Object.entries(out)) out[id] = conPercentuale(rec.presenti, rec.totali);
return out;
}
+16 -58
View File
@@ -1,78 +1,36 @@
import { useMemo } from "react";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import { useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
import {
presenzeUltimoMese,
presenzeUltimoMeseTutti,
type StatistichePresenza,
} from "./presenze-mese-core";
/**
* Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni.
* Usa solo cache già in memoria: nessuna query aggiuntiva.
* Usa solo cache già in memoria: nessuna query aggiuntiva. Il calcolo sta in
* `presenze-mese-core.ts`, qui c'è solo il collegamento agli hook.
*/
export function usePresenzeUltimoMese(giocatoreId: string | undefined) {
export function usePresenzeUltimoMese(giocatoreId: string | undefined): StatistichePresenza {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
return useMemo(() => {
if (!giocatoreId) return { presenti: 0, totali: 0, percentuale: 0 };
const oggi = new Date();
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
const da = inizio.toISOString().slice(0, 10);
const a = oggi.toISOString().slice(0, 10);
const rilevanti = eventi.filter(
(e) =>
(e.tipo === "partita" || e.tipo === "allenamento") &&
e.data >= da &&
e.data <= a &&
(e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
);
const presenti = rilevanti.filter((e) => {
const stato = presenze[e.id]?.[giocatoreId];
return stato === "presente" || stato === "ritardo";
}).length;
const totali = rilevanti.length;
return {
presenti,
totali,
percentuale: totali ? Math.round((presenti / totali) * 100) : 0,
};
}, [eventi, presenze, giocatoreId]);
return useMemo(
() => presenzeUltimoMese(giocatoreId, eventi, presenze),
[eventi, presenze, giocatoreId],
);
}
/** Percentuale presenze ultimi 30 giorni per tutti i giocatori (mappa per id). */
export function usePresenzeUltimoMeseTutti(): Record<
string,
{ presenti: number; totali: number; percentuale: number }
> {
export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza> {
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => {
const oggi = new Date();
const inizio = new Date(oggi);
inizio.setDate(inizio.getDate() - 30);
const da = inizio.toISOString().slice(0, 10);
const a = oggi.toISOString().slice(0, 10);
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
const rilevanti = eventi.filter(
(e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a,
);
const out: Record<string, { presenti: number; totali: number; percentuale: number }> = {};
for (const e of rilevanti) {
const ids = e.convocati.length > 0 ? e.convocati : idRosa;
for (const id of ids) {
const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 });
rec.totali += 1;
const stato = presenze[e.id]?.[id];
if (stato === "presente" || stato === "ritardo") rec.presenti += 1;
}
}
for (const rec of Object.values(out)) {
rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0;
}
return out;
const idRosa = squadra.filter(inRosa).map((g) => g.id);
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
}, [eventi, presenze, squadra]);
}
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more