11 Commits
Author SHA1 Message Date
davide 2f583ca97f Merge develop: versione 1.4.1 2026-10-19 08:31:53 +02:00
davideandClaude Sonnet 5.5 7996dd050e CHANGELOG: la correzione della card evento è parte della 1.4.1
Sposta la voce da «Non rilasciato» alla sezione 1.4.1.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:21:14 +02:00
44 changed files with 1116 additions and 836 deletions

No files matched your search

+10 -9
View File
@@ -9,14 +9,16 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.3.0 (tag `v1.3.0` su `main`) — notifica di compleanno, obiettivi cliccabili con card di - **Versione:** 1.4.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — gli eventi con convocati si vedono
dettaglio, presenze calcolate sui convocati, avvisi in Home ridisegnati, caratteri più grandi, tessera CSI solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0: avviso «Presenza modificata» ad admin e
tolta dalla dashboard. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a - **Migration:** 44 file in `supabase/migrations/`, fino a `m33_presenza_modificata_allenatori`. Tutte applicate in produzione (verificato con `npx supabase migration list` il
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il 09/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. `foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
@@ -32,9 +34,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo. - Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno - Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
slot lo occupa anche in produzione e va liberato da un admin. slot lo occupa anche in produzione e va liberato da un admin.
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99 - Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
presenti.
## Note ## Note
+51
View File
@@ -13,6 +13,57 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.4.1] - 2026-10-19
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
Nessuna migration.
### Modificato
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
### Corretto
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
vuoto resta «tutta la rosa».
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
eventi a cui non sei convocato.
## [1.4.0] - 2026-10-09
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
### Aggiunto
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
## [1.3.1] - 2026-10-06
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
### Modificato
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
(«Nessun compleanno questo mese»).
### Rimosso
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
## [1.3.0] - 2026-10-06 ## [1.3.0] - 2026-10-06
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
+14 -13
View File
@@ -77,7 +77,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. | | `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `aggiornato_il` è l'ultima modifica. | | `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | | `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
@@ -122,11 +122,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data. - Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e - Il `CHECK` sul tipo ammette anche `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali. `sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi - Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici (compleanni compresi) ma non quelli dei pulsanti. automatici (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`) ### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
@@ -152,16 +152,17 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in 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. [modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` | | Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | | `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | | `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | | `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | | `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) | | `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `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. | — | | `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | | `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. | — |
| `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). | — | | `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge ## Badge
+1 -1
View File
@@ -12,7 +12,7 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. | | **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. | | **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). | | **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti, partite, conferme entro 24 ore). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). | | **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). | | **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). | | **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). | | **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
+2
View File
@@ -49,6 +49,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push, - [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047) centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
## Prossimo ## Prossimo
+73
View File
@@ -0,0 +1,73 @@
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
**Data:** 8 ottobre 2026
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
**Contesto**
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
**Decisione**
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
evento). Scatta per **qualsiasi
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
modifica la fa un admin per conto suo.
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
ricevere notifiche, perché `notifiche_utente` è per slot.
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
notifiche e cinque email.
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
**Fase di prova (conclusa)**
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
ora vale la regola del punto 2.
**Alternative scartate**
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
**Conseguenze**
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
[allenatore.md](../modules/allenatore.md#notifiche).
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
per evento, non togliere l'avviso.
**Riesame**
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
sono troppe o poche, spostare la soglia (una costante nella funzione).
+50 -49
View File
@@ -46,55 +46,56 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate** **Accettate**
| ID | Titolo | | ID | Titolo |
| ------------------- | ------------------------------------------- | | ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable | | [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first | | [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive | | [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
| [DD-005](DD-005.md) | Mobile-first | | [DD-005](DD-005.md) | Mobile-first |
| [DD-006](DD-006.md) | AI solo se utile | | [DD-006](DD-006.md) | AI solo se utile |
| [DD-007](DD-007.md) | Badge calcolati, non in DB | | [DD-007](DD-007.md) | Badge calcolati, non in DB |
| [DD-008](DD-008.md) | Gamification equa tra ruoli | | [DD-008](DD-008.md) | Gamification equa tra ruoli |
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API | | [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
| [DD-010](DD-010.md) | Niente storico certificati | | [DD-010](DD-010.md) | Niente storico certificati |
| [DD-011](DD-011.md) | Auth reale prima del profilo | | [DD-011](DD-011.md) | Auth reale prima del profilo |
| [DD-012](DD-012.md) | Rimandare la migrazione ID | | [DD-012](DD-012.md) | Rimandare la migrazione ID |
| [DD-013](DD-013.md) | Portabilità dello stack | | [DD-013](DD-013.md) | Portabilità dello stack |
| [DD-015](DD-015.md) | Rosa da hardcoded a DB | | [DD-015](DD-015.md) | Rosa da hardcoded a DB |
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore | | [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore | | [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
| [DD-018](DD-018.md) | Collegamento automatico per email | | [DD-018](DD-018.md) | Collegamento automatico per email |
| [DD-019](DD-019.md) | Il branch lo decide l'utente | | [DD-019](DD-019.md) | Il branch lo decide l'utente |
| [DD-020](DD-020.md) | Test obbligatori e verdi | | [DD-020](DD-020.md) | Test obbligatori e verdi |
| [DD-021](DD-021.md) | Molle interrompibili con motion | | [DD-021](DD-021.md) | Molle interrompibili con motion |
| [DD-022](DD-022.md) | App solo chiara | | [DD-022](DD-022.md) | App solo chiara |
| [DD-023](DD-023.md) | Scritture limitate per ruolo | | [DD-023](DD-023.md) | Scritture limitate per ruolo |
| [DD-024](DD-024.md) | Route di notifica autenticate | | [DD-024](DD-024.md) | Route di notifica autenticate |
| [DD-025](DD-025.md) | Promemoria palloni manuale | | [DD-025](DD-025.md) | Promemoria palloni manuale |
| [DD-026](DD-026.md) | Payload push cifrato | | [DD-026](DD-026.md) | Payload push cifrato |
| [DD-027](DD-027.md) | Voto limitato ai convocati | | [DD-027](DD-027.md) | Voto limitato ai convocati |
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP | | [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato | | [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
| [DD-030](DD-030.md) | Centro notifiche in-app | | [DD-030](DD-030.md) | Centro notifiche in-app |
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata | | [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare | | [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
| [DD-033](DD-033.md) | Gestione eventi solo da calendario | | [DD-033](DD-033.md) | Gestione eventi solo da calendario |
| [DD-034](DD-034.md) | Ruolo allenatore | | [DD-034](DD-034.md) | Ruolo allenatore |
| [DD-035](DD-035.md) | Avviso certificati in Home | | [DD-035](DD-035.md) | Avviso certificati in Home |
| [DD-036](DD-036.md) | Notifiche email via Gmail | | [DD-036](DD-036.md) | Notifiche email via Gmail |
| [DD-037](DD-037.md) | Promemoria evento senza ritorni | | [DD-037](DD-037.md) | Promemoria evento senza ritorni |
| [DD-038](DD-038.md) | Tre canali per ogni notifica | | [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-046](DD-046.md) | Avviso certificati mancanti | | [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | | [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore | | [DD-044](DD-044.md) | Via la foto tessera dal profilo |
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore | | [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni | | [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione** **In valutazione**
+1
View File
@@ -133,6 +133,7 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
| Turno palloni | no | I turni riguardano solo i giocatori. | | 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. | | Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. | | Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. | | 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 Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
+28 -34
View File
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
## Elenco badge ## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le 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 condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia. informazioni verso l'interfaccia.
@@ -93,7 +93,6 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 | | `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 | | `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-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) ### Badge segreti (booleani, nascosti finché non sbloccati)
@@ -104,13 +103,12 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è `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()`. `grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta | | 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-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-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-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`) |
| `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) ### Badge social (votati dai compagni, 5 categorie per partita)
@@ -149,24 +147,22 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
### Riepilogo per badge ### Riepilogo per badge
| # | id | tipo | test unit | test integration | | # | id | tipo | test unit | test integration |
| --- | ------------------- | ------- | ----------------------------- | --------------------------------------------- | | --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) | | 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) | | 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) | | 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) | | 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) | | 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) | | 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) | | 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) | | 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) | | 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) | | 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) | | 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) | | 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) | | 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) | | 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
--- ---
@@ -193,11 +189,9 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
## Limiti noti ## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra", - **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e rimossi il 2026-10-06.
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
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à - Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente. "ottenuto" può sparire o apparire retroattivamente.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando - Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
@@ -213,7 +207,7 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza. conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto` **Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo 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 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 segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
@@ -238,7 +232,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
## Evoluzioni possibili ## Evoluzioni possibili
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage. - Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero, - Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
ora che le serie sono calcolate. ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo. - Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`. - Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
+5 -1
View File
@@ -24,7 +24,11 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori: in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe 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 vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio. di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
selezionato.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori - **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)): (`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi ## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla **Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **Fa eccezione il turno palloni** (DD-040, dalla
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il `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 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 viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
+17 -14
View File
@@ -44,10 +44,11 @@ quelle del turno palloni**, che restano solo push e in-app: DD-040).
| 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ì | | 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 | | 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì | | 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La Le notifiche generate dal server (righe 1-6, 8 e 9) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
automatiche (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei automatiche (4, 5, 8 e 9; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»). lo stesso evento (vedi «Centro notifiche in-app»).
@@ -75,6 +76,7 @@ disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `G
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | | 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo | | 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso | | 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26` Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`, (`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
@@ -102,7 +104,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un - Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze. promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
- L'arrivo di un risultato, di una pagella o di un voto MVP. - L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi. - Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
- Assenze e infortuni. - Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore. - La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035). - Il certificato in scadenza: resta una card in Home (DD-035).
@@ -158,17 +160,18 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
### Test che verificano il catalogo ### Test che verificano il catalogo
| Cosa | Test | | Cosa | Test |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) | | Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` | | 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` | | 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` | | 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` | | Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` | | Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` | | Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` | | Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | | 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 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 consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
+18 -37
View File
@@ -1,12 +1,12 @@
# Modulo — Serie di presenze # Modulo — Serie di presenze
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali **Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`, **File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx` `src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il` **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/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts` `test/integration/serie-allenamenti-badge.test.ts`
--- ---
@@ -19,15 +19,18 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
--- ---
## Le tre serie in sintesi ## Le due serie in sintesi
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi | | Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ | | ------------- | ------------------ | -------------------------------------------- | ------------ |
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 | | `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 | | `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
restano nel database, ma oggi nessuna statistica li usa.
Esiste un terzo contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e "presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
`src/routes/profilo.tsx`. `src/routes/profilo.tsx`.
@@ -67,10 +70,10 @@ il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa 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. rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
| Colonna | Cosa registra | Chi la usa | | Colonna | Cosa registra | Chi la usa |
| --------------- | --------------------- | ----------------------- | | --------------- | --------------------- | -------------------------------------- |
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" | | `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica | | `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta. risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
@@ -147,26 +150,6 @@ Conseguenze da conoscere prima di cambiare qualcosa:
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti. significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`. - Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
### `serieConferme()` — conferme entro 24 ore
```ts
serieConferme(giocatoreId, eventi, tempi, oggi?)
```
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
sulla reattività, non sulla presenza.
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
l'istante giusto da confrontare.
### Lettura e cache ### Lettura e cache
`fetchPresenze()` fa **una sola query** e costruisce due mappe: `fetchPresenze()` fa **una sola query** e costruisce due mappe:
@@ -263,8 +246,6 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
| Dove | Cosa | Soglie | | Dove | Cosa | Soglie |
| ------------------------------- | -------------------------------------------------------------- | --------------------------- | | ------------------------------- | -------------------------------------------------------------- | --------------------------- |
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 | | `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 | | `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
--- ---
@@ -320,8 +301,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa **`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é `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 `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 offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date` `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 (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". italiana potevano non essere d'accordo su cosa fosse "oggi".
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.4.1",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.4.1",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+4 -3
View File
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store"; import { useGiocatoreInCampo } from "@/lib/user-store";
@@ -75,7 +75,8 @@ export function EventoCard({
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme. // Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter(inRosa); // Solo i convocati contano e rispondono (vuoto = tutta la rosa).
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
const stato = io ? risposte[io.id] : undefined; const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter( const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
@@ -187,7 +188,7 @@ export function EventoCard({
</p> </p>
) : null} ) : null}
{io ? ( {io && eConvocato(evento, io.id) ? (
<div <div
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")} className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
role="group" role="group"
+7 -3
View File
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra"; import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data"; import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import type { EventoTipo } from "@/lib/eventi"; import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store"; import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -20,9 +20,12 @@ export function RosaPresenze({
eventoId, eventoId,
data, data,
tipo = "allenamento", tipo = "allenamento",
convocati = [],
}: { }: {
eventoId: string; eventoId: string;
data: string; data: string;
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
convocati?: string[];
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */ /** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo; tipo?: EventoTipo;
}) { }) {
@@ -34,7 +37,8 @@ export function RosaPresenze({
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034). // Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase(); const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi(); const puoSollecitare = usePuoGestireEventi();
const rosa = useAnagraficaRosa(); const anagrafica = useAnagraficaRosa();
const rosa = rosaConvocata(anagrafica, convocati);
const [sollecito, setSollecito] = useState(false); const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi(); const passato = data < dataOggi();
@@ -97,7 +101,7 @@ export function RosaPresenze({
</span> </span>
</div> </div>
{io ? ( {io && rosa.some((g) => g.id === io.id) ? (
<div className="mt-4 border-t border-border pt-3"> <div className="mt-4 border-t border-border pt-3">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta La tua risposta
-23
View File
@@ -3,9 +3,7 @@ import {
Repeat, Repeat,
Lock, Lock,
Ghost, Ghost,
Zap,
Rocket, Rocket,
Anchor,
Stethoscope, Stethoscope,
AlarmClock, AlarmClock,
ClipboardCheck, ClipboardCheck,
@@ -114,15 +112,6 @@ export const badgeDefs: BadgeDef[] = [
soglie: { bronzo: 3, argento: 6, oro: 10 }, soglie: { bronzo: 3, argento: 6, oro: 10 },
valore: (g) => g.serieAllenamenti, valore: (g) => g.serieAllenamenti,
}, },
{
id: "serie-conferme",
nome: "Risposta lampo",
descrizione: "Conferme rapide agli eventi: la squadra sa subito che ci sei.",
unita: "conferme entro 24h",
icon: Zap,
soglie: { bronzo: 3, argento: 8, oro: 15 },
valore: (g) => g.serieConferme,
},
]; ];
/** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */ /** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */
@@ -141,18 +130,6 @@ export const badgeSegreti: BadgeDef[] = [
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0), valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
celebrazione: "Nei momenti caldi ci sei sempre.", celebrazione: "Nei momenti caldi ci sei sempre.",
}, },
{
id: "s-mai-forfait",
nome: "Mai un forfait",
descrizione:
"Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
unita: "requisito nascosto",
icon: Anchor,
segreto: true,
soglie: { bronzo: 1, argento: 1, oro: 1 },
valore: (g) => (g.serieConferme >= 10 && g.presenze >= 15 ? 1 : 0),
celebrazione: "Su di te la squadra può contare a occhi chiusi.",
},
{ {
id: "s-infermeria", id: "s-infermeria",
nome: "Cliente VIP dell'Infermeria", nome: "Cliente VIP dell'Infermeria",
-2
View File
@@ -34,7 +34,6 @@ export type Giocatore = {
/** Serie consecutive per tipo: si azzerano in modo indipendente. */ /** Serie consecutive per tipo: si azzerano in modo indipendente. */
serieAllenamenti: number; serieAllenamenti: number;
seriePartite: number; seriePartite: number;
serieConferme: number;
/** MVP eletti dalla squadra. */ /** MVP eletti dalla squadra. */
mvp: number; mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */ /** Media delle pagelle ricevute dai compagni (1-10). */
@@ -109,7 +108,6 @@ export const giocatori: Giocatore[] = rosaCSI
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
mvp: 0, mvp: 0,
mediaVoto: 0, mediaVoto: 0,
votiPagella: 0, votiPagella: 0,
+14
View File
@@ -0,0 +1,14 @@
import { eventoVisibile, useEventi } from "./eventi";
import { usePuoGestireEventi } from "./ruoli";
import { useGiocatoreId } from "./user-store";
/** Gli eventi che l'utente corrente può vedere (calendario, home, elenco). */
export function useEventiVisibili() {
const { eventi, ...resto } = useEventi();
const giocatoreId = useGiocatoreId();
const vedeTutto = usePuoGestireEventi();
return {
...resto,
eventi: eventi.filter((e) => eventoVisibile(e, giocatoreId, vedeTutto)),
};
}
+26 -2
View File
@@ -90,6 +90,25 @@ export function useEventi() {
return { ...query, eventi: query.data ?? [] }; return { ...query, eventi: query.data ?? [] };
} }
/** Il giocatore è tra i convocati (elenco vuoto = tutta la rosa). */
export function eConvocato(evento: Pick<Evento, "convocati">, giocatoreId: string): boolean {
return evento.convocati.length === 0 || evento.convocati.includes(giocatoreId);
}
/**
* Chi vede un evento: i convocati; se `convocati` è vuoto, tutta la rosa. Chi gestisce gli
* eventi (admin e allenatori) li vede sempre, convocato o no.
*/
export function eventoVisibile(
evento: Pick<Evento, "convocati">,
giocatoreId: string | null,
vedeTutto: boolean,
): boolean {
if (vedeTutto) return true;
if (evento.convocati.length === 0) return true;
return giocatoreId !== null && eConvocato(evento, giocatoreId);
}
export function useEvento(id: string) { export function useEvento(id: string) {
const { eventi, ...resto } = useEventi(); const { eventi, ...resto } = useEventi();
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null }; return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
@@ -198,6 +217,11 @@ export function compleanniEventi(
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */ /** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) { export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
if (!evento || evento.convocati.length === 0) return rosa; return rosaConvocata(rosa, evento?.convocati ?? []);
return rosa.filter((g) => evento.convocati.includes(g.id)); }
/** Restringe una rosa ai convocati indicati: elenco vuoto = tutta la rosa. */
export function rosaConvocata<T extends { id: string }>(rosa: T[], convocati: string[]): T[] {
if (convocati.length === 0) return rosa;
return rosa.filter((g) => convocati.includes(g.id));
} }
+2 -1
View File
@@ -26,7 +26,8 @@ export type TipoNotifica =
| "sollecita_presenze_6h" | "sollecita_presenze_6h"
| "sondaggio_cacche" | "sondaggio_cacche"
| "compleanno" | "compleanno"
| "compleanno_auguri"; | "compleanno_auguri"
| "presenza_modificata";
export type NotificaUtente = { export type NotificaUtente = {
id: string; id: string;
+1 -26
View File
@@ -155,31 +155,6 @@ export function serieConsecutiva(
); );
} }
const ORE_24 = 24 * 60 * 60 * 1000;
/**
* Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione
* (`creatoIl` dell'evento). Gli eventi senza istante di creazione — quelli generati
* dal client, non salvati a database — non spezzano la serie: vengono saltati.
*/
export function serieConferme(
giocatoreId: string,
eventi: Evento[],
tempi: MappaTempiRisposta,
oggi: string = dataOggi(),
): number {
return serieSu(
giocatoreId,
eventi.filter((e) => e.creatoIl),
oggi,
undefined,
(e) => {
const risposto = tempi[e.id]?.[giocatoreId];
return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24;
},
);
}
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */ /** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
function serieSu( function serieSu(
giocatoreId: string, giocatoreId: string,
@@ -228,7 +203,7 @@ export function usePresenzeEvento(eventoId: string) {
* *
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il` * - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima, * non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h"; * e un ripensamento non deve cambiare l'istante della prima risposta;
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore * - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta. * risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
*/ */
+2 -15
View File
@@ -12,7 +12,6 @@ import { useEventi } from "./eventi";
import { import {
contaPartiteGiocate, contaPartiteGiocate,
contaPresenzeGiocatore, contaPresenzeGiocatore,
serieConferme,
serieConsecutiva, serieConsecutiva,
totaliEventiGiocatore, totaliEventiGiocatore,
useRispostePresenze, useRispostePresenze,
@@ -68,7 +67,7 @@ export function useRosa(): Giocatore[] {
const { salvati: turniSalvati } = useTurniPalloni(); const { salvati: turniSalvati } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi(); const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi(); const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze(); const { presenze: mappaPresenze } = useRispostePresenze();
const votiMvp = voti.data; const votiMvp = voti.data;
@@ -94,7 +93,6 @@ export function useRosa(): Giocatore[] {
streak: serieConsecutiva(g.id, eventi, mappaPresenze), streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0, mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0, mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0, votiPagella: medie[g.id]?.voti ?? 0,
@@ -105,18 +103,7 @@ export function useRosa(): Giocatore[] {
infortuni: infortuni[g.id] ?? 0, infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0, ritardi: ritardi[g.id] ?? 0,
})); }));
}, [ }, [squadra, votiMvp, pagelle, cacche, turniSalvati, infortuni, ritardi, eventi, mappaPresenze]);
squadra,
votiMvp,
pagelle,
cacche,
turniSalvati,
infortuni,
ritardi,
eventi,
mappaPresenze,
tempi,
]);
} }
/** Criteri di ordinamento della classifica interna di Squadra. */ /** Criteri di ordinamento della classifica interna di Squadra. */
+2 -10
View File
@@ -1,8 +1,8 @@
import { Dumbbell, Swords, Zap } from "lucide-react"; import { Dumbbell, Swords } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
export type SerieTipo = "allenamenti" | "partite" | "conferme"; export type SerieTipo = "allenamenti" | "partite";
export type SerieDef = { export type SerieDef = {
tipo: SerieTipo; tipo: SerieTipo;
@@ -31,14 +31,6 @@ export const serieDefs: SerieDef[] = [
traguardi: [2, 5, 8, 12], traguardi: [2, 5, 8, 12],
valore: (g) => g.seriePartite, valore: (g) => g.seriePartite,
}, },
{
tipo: "conferme",
label: "Conferme 24h",
descrizione: "Risposte date entro 24 ore dalla convocazione",
icon: Zap,
traguardi: [3, 8, 15, 20],
valore: (g) => g.serieConferme,
},
]; ];
export type SerieStato = { export type SerieStato = {
+6 -1
View File
@@ -93,7 +93,12 @@ function AllenamentoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
+16 -6
View File
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion"; import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { usePuoGestireEventi } from "@/lib/ruoli"; import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
@@ -56,7 +57,7 @@ function Calendario() {
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null); const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false); const [drawerAperto, setDrawerAperto] = useState(false);
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const rosa = useAnagraficaRosa({ conAllenatori: true }); const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo // `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi. // `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
@@ -90,6 +91,13 @@ function Calendario() {
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia). // Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null; const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : []; const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
// Solo i compleanni del mese in corso non ancora passati (oggi compreso), a prescindere dal mese
// mostrato nella griglia.
const compleanniMeseCorrente =
oggi && oggiIso
? compleanniEventi(rosa, oggi.anno).filter((c) => c.data.startsWith(oggiIso.slice(0, 7)))
: [];
const prossimiCompleanni = oggiIso ? compleanniMeseCorrente.filter((c) => c.data >= oggiIso) : [];
return ( return (
<> <>
@@ -270,13 +278,15 @@ function Calendario() {
</div> </div>
</Section> </Section>
<Section titolo="Compleanni"> <Section titolo="Prossimi compleanni">
<div className="space-y-3"> <div className="space-y-3">
{compleanniMese.length > 0 ? ( {prossimiCompleanni.length > 0 ? (
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />) prossimiCompleanni.map((c) => <EventoCard key={c.id} evento={c} />)
) : ( ) : (
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card"> <p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun compleanno in {mesiIT[mese]!.toLowerCase()} {compleanniMeseCorrente.length > 0
? "Nessun altro compleanno questo mese"
: "Nessun compleanno questo mese"}
</p> </p>
)} )}
</div> </div>
+2 -2
View File
@@ -1,3 +1,4 @@
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react"; import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
@@ -22,7 +23,6 @@ import {
daCategoria, daCategoria,
eventoVuoto, eventoVuoto,
useEliminaEvento, useEliminaEvento,
useEventi,
useSalvaEvento, useSalvaEvento,
type CategoriaEvento, type CategoriaEvento,
type Evento, type Evento,
@@ -65,7 +65,7 @@ function GestioneEventi() {
const io = useGiocatoreBase(); const io = useGiocatoreBase();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso. // Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi(); const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi(); const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento(); const salva = useSalvaEvento();
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
+6 -1
View File
@@ -88,7 +88,12 @@ function EventoDetail() {
</Section> </Section>
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
+4 -3
View File
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero"; import { Numero } from "@/components/motion/Numero";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi"; import { eConvocato, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useRispostePresenze } from "@/lib/presenze"; import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa"; import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra"; import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
@@ -47,14 +48,14 @@ function Index() {
const base = useGiocatoreBase(); const base = useGiocatoreBase();
const giocatore = useIo(); const giocatore = useIo();
const allenatore = isAllenatore(base); const allenatore = isAllenatore(base);
const { eventi } = useEventi(); const { eventi } = useEventiVisibili();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10); const oggi = new Date().toISOString().slice(0, 10);
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3); const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
const prossimo = prossimi[0] ?? null; const prossimo = prossimi[0] ?? null;
const daConfermare = prossimi const daConfermare = prossimi
.slice(1) .slice(1)
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]); .filter((e) => !giocatore || (eConvocato(e, giocatore.id) && !presenze[e.id]?.[giocatore.id]));
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null; const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
const { data: csi } = useCsi(); const { data: csi } = useCsi();
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra)); const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
+6 -1
View File
@@ -297,7 +297,12 @@ function PartitaDetail() {
) : null} ) : null}
<Section titolo="Rosa e presenze"> <Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} /> <RosaPresenze
eventoId={evento.id}
data={evento.data}
tipo={evento.tipo}
convocati={evento.convocati}
/>
</Section> </Section>
</> </>
); );
@@ -0,0 +1,140 @@
-- M32 — Avviso se una presenza cambia a meno di 6 ore dall'evento (DD-050)
--
-- Quando una riga di `risposte_presenze` cambia (nuova, modificata o ritirata) e mancano da 0 a 6 ore
-- all'inizio di una partita o di un allenamento, ai destinatari arriva una notifica «Presenza modificata».
--
-- FASE DI PROVA: i destinatari sono solo gli admin (DD-050, «Fase di prova»). Per aprirla agli allenatori
-- basta aggiungere 'allenatore' alla lista dei ruoli qui sotto, con una nuova migration.
--
-- Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'email, quello delle push
-- (esteso qui con il tipo nuovo) accoda la push, la riga è il centro notifiche in-app (DD-038).
-- --- Tipo di notifica --------------------------------------------------------------------------
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
tipo IN (
'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',
'compleanno',
'compleanno_auguri',
'presenza_modificata'
)
);
-- --- Funzione e trigger ------------------------------------------------------------------------
--
-- `evento_id` resta NULL (come i messaggi `admin`): la UNIQUE (giocatore_id, evento_id, tipo) non si
-- applica e due modifiche dello stesso evento restano due notifiche distinte.
--
-- Cancellare un evento elimina le risposte con un trigger AFTER DELETE su `eventi_app` (M14): quando
-- scatta questo, l'evento non esiste più, quindi non parte nessun avviso.
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
ev record;
chi record;
BEGIN
IF prima IS NOT DISTINCT FROM dopo THEN
RETURN NULL;
END IF;
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
INTO ev
FROM public.eventi_app e
WHERE e.id = evento_id_r;
IF NOT FOUND
OR ev.tipo NOT IN ('partita', 'allenamento')
OR ev.inizio IS NULL
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
RETURN NULL;
END IF;
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
INTO chi
FROM public.giocatori_squadra g
WHERE g.id = giocatore_r;
IF NOT FOUND THEN
RETURN NULL;
END IF;
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
SELECT d.id,
'presenza_modificata',
'Presenza modificata: ' || chi.nome_completo,
'Evento: ' || ev.titolo
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|| E'\nA: ' || public.nome_stato_presenza(dopo)
FROM public.giocatori_squadra d
WHERE d.attivo = true
AND d.id <> giocatore_r
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = d.auth_user_id
AND ur.role IN ('admin')
);
RETURN NULL;
END;
$$;
REVOKE ALL ON FUNCTION public.avvisa_presenza_modificata() FROM PUBLIC, anon, authenticated;
CREATE OR REPLACE FUNCTION public.nome_stato_presenza(p_stato text)
RETURNS text
LANGUAGE sql IMMUTABLE AS $$
SELECT CASE WHEN p_stato = 'ritardo' THEN 'in ritardo' ELSE coalesce(p_stato, 'nessuna risposta') END;
$$;
CREATE TRIGGER risposte_presenze_avvisa_modifica
AFTER INSERT OR UPDATE OF stato OR DELETE ON public.risposte_presenze
FOR EACH ROW
EXECUTE FUNCTION public.avvisa_presenza_modificata();
-- --- Push: anche la presenza modificata --------------------------------------------------------
--
-- Stesso trigger di M31 con il tipo nuovo. L'email passa già dal trigger di M22/M26.
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
AFTER INSERT ON public.notifiche_utente
FOR EACH ROW
WHEN (NEW.tipo IN (
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h',
'compleanno',
'compleanno_auguri',
'presenza_modificata'
))
EXECUTE FUNCTION public.accoda_push_promemoria();
@@ -0,0 +1,66 @@
-- M33 — «Presenza modificata» anche agli allenatori (DD-050)
--
-- Fine della fase di prova della M32: l'avviso arriva agli admin e agli allenatori attivi (ruolo in
-- `user_roles` e slot attivo), tranne il giocatore a cui appartiene la presenza. Cambia solo la lista
-- dei ruoli nella funzione; trigger, tipo di notifica e code push/email restano quelli della M32.
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
ev record;
chi record;
BEGIN
IF prima IS NOT DISTINCT FROM dopo THEN
RETURN NULL;
END IF;
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
INTO ev
FROM public.eventi_app e
WHERE e.id = evento_id_r;
IF NOT FOUND
OR ev.tipo NOT IN ('partita', 'allenamento')
OR ev.inizio IS NULL
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
RETURN NULL;
END IF;
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
INTO chi
FROM public.giocatori_squadra g
WHERE g.id = giocatore_r;
IF NOT FOUND THEN
RETURN NULL;
END IF;
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
SELECT d.id,
'presenza_modificata',
'Presenza modificata: ' || chi.nome_completo,
'Evento: ' || ev.titolo
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|| E'\nA: ' || public.nome_stato_presenza(dopo)
FROM public.giocatori_squadra d
WHERE d.attivo = true
AND d.id <> giocatore_r
AND EXISTS (
SELECT 1
FROM public.user_roles ur
WHERE ur.user_id = d.auth_user_id
AND ur.role IN ('admin', 'allenatore')
);
RETURN NULL;
END;
$$;
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | Cartella | Cosa verifica | Serve rete? |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | | `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), l'avviso «Presenza modificata» ad admin e allenatori con finestra di 6 ore, stati e nessun avviso a cancellazione dell'evento (`presenza-modificata`, M32-M33, DD-050), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio ## Server di sviluppo con Node vecchio
@@ -0,0 +1,440 @@
/**
* Avviso «Presenza modificata» (M32 e M33, DD-050): `bun test/integration/presenza-modificata.test.ts`.
*
* Scrive su `risposte_presenze` come farebbe l'app e verifica, sul database vero:
*
* - una risposta nuova, modificata o ritirata a meno di 6 ore da una partita o da un allenamento
* avvisa gli admin e gli allenatori attivi, con titolo e corpo del catalogo;
* - riceve l'avviso chi ha il ruolo admin o allenatore con uno slot attivo (M33); non riceve nulla un giocatore semplice, e non riceve
* nulla l'admin a cui appartiene la presenza;
* - nessun avviso se lo stato non cambia, se l'evento è oltre le 6 ore, già iniziato, o di un tipo che
* non è partita né allenamento, né quando una risposta sparisce perché l'evento è stato cancellato;
* - ogni modifica è una notifica a sé (nessun raggruppamento) e ha la coda email e la coda push.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4` (chi risponde), `g5` (admin),
* `g6` (giocatore), `g9` (allenatore inattivo) e `g10` (allenatore) della rosa seed, ripristinati alla fine.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("presenza modificata", "stack locale non attivo (npx supabase start)");
riepilogo("presenza-modificata");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`presenza modificata su ${URL_BASE}`);
const PASSWORD = "prova-presenza-modificata-123";
const RISPONDE = "g4";
const ADMIN = "g5";
const GIOCATORE = "g6";
const ALLENATORE = "g10";
const INATTIVO = "g9";
const PREFISSO = "test-presenza-mod-";
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function leggi<T>(percorso: string): Promise<T[]> {
const res = await rest(percorso, SERVIZIO);
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
return (await res.json()) as T[];
}
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
const res = await rest(percorso, token, {
method: metodo,
body: corpo === undefined ? null : JSON.stringify(corpo),
});
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
/** Data (AAAA-MM-GG) e ora (HH:MM) a Roma, tra `ore` ore da adesso. */
function traOre(ore: number): { data: string; ora: string } {
const parti: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-GB", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(Date.now() + ore * 3_600_000))
.map((p) => [p.type, p.value]),
);
return {
data: `${parti["year"]}-${parti["month"]}-${parti["day"]}`,
ora: `${parti["hour"]}:${parti["minute"]}`,
};
}
type Riga = { id: string; giocatore_id: string; titolo: string; corpo: string };
const notifiche = () =>
leggi<Riga>(
"notifiche_utente?tipo=eq.presenza_modificata&select=id,giocatore_id,titolo,corpo&order=creato_il",
);
const svuota = async () => {
await rest("notifiche_utente?tipo=eq.presenza_modificata", SERVIZIO, { method: "DELETE" });
};
const risposta = (evento: string, stato: string) =>
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
evento_id: evento,
giocatore_id: RISPONDE,
stato,
});
const aggiorna = (evento: string, stato: string, giocatore = RISPONDE) =>
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
stato,
});
const ritira = (evento: string) =>
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${RISPONDE}`, "DELETE");
async function creaEvento(id: string, tipo: string, ore: number, titolo = `Prova ${id}`) {
const { data, ora } = traOre(ore);
await scrivi("eventi_app", "POST", { id, tipo, titolo, data, ora });
return { data, ora, titolo };
}
let tokenAdmin = "";
const idUtenti: string[] = [];
const idEventi: string[] = [];
const originali = new Map<string, Record<string, unknown>>();
try {
const admin = await creaUtente(`${PREFISSO}admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`${PREFISSO}allenatore-${Date.now()}@example.test`);
const inattivo = await creaUtente(`${PREFISSO}inattivo-${Date.now()}@example.test`);
const senzaSlot = await creaUtente(`${PREFISSO}senza-slot-${Date.now()}@example.test`);
idUtenti.push(admin.id, allenatore.id, inattivo.id, senzaSlot.id);
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [RISPONDE, ADMIN, GIOCATORE, ALLENATORE, INATTIVO]) {
const [riga] = await leggi<Record<string, unknown>>(
`giocatori_squadra?id=eq.${id}&select=tipo,attivo,auth_user_id`,
);
originali.set(id, riga ?? { tipo: "giocatore", attivo: true, auth_user_id: null });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(
`giocatori_squadra?id=eq.${ADMIN}`,
"PATCH",
{ auth_user_id: admin.id },
tokenAdmin,
);
await scrivi(
`giocatori_squadra?id=eq.${ALLENATORE}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: allenatore.id },
tokenAdmin,
);
// Un allenatore con slot disattivato, un admin senza nessuno slot e un admin che ha anche il
// ruolo allenatore: nessuno dei primi due può ricevere, il terzo riceve una sola notifica.
await scrivi(
`giocatori_squadra?id=eq.${INATTIVO}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: inattivo.id, attivo: false },
tokenAdmin,
);
await scrivi("user_roles", "POST", { user_id: senzaSlot.id, role: "admin" });
await scrivi("user_roles", "POST", { user_id: admin.id, role: "allenatore" });
await svuota();
const [chi] = await leggi<{ nome: string; cognome: string }>(
`giocatori_squadra?id=eq.${RISPONDE}&select=nome,cognome`,
);
const nomeCompleto = `${chi!.nome} ${chi!.cognome}`.trim();
// Il seed può avere altri admin con uno slot attivo: l'elenco atteso lo calcola il test.
const attesi = async () => {
const ruoli = await leggi<{ user_id: string }>(
"user_roles?role=in.(admin,allenatore)&select=user_id",
);
const slot = await leggi<{ id: string; auth_user_id: string }>(
"giocatori_squadra?attivo=eq.true&auth_user_id=not.is.null&select=id,auth_user_id",
);
const idAdmin = new Set(ruoli.map((r) => r.user_id));
return slot
.filter((s) => idAdmin.has(s.auth_user_id) && s.id !== RISPONDE)
.map((s) => s.id)
.sort();
};
// --- Partita a 3 ore ---------------------------------------------------------------------
const ev = await creaEvento(`${PREFISSO}partita`, "partita", 3, "Partita di prova");
idEventi.push(`${PREFISSO}partita`);
const dataIt = `${ev.data.slice(8, 10)}/${ev.data.slice(5, 7)}/${ev.data.slice(0, 4)}`;
await prova(
"risposta nuova: avvisa admin e allenatori, con il testo del catalogo",
async () => {
await risposta(`${PREFISSO}partita`, "presente");
const righe = await notifiche();
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), await attesi());
assert.ok(
righe.some((r) => r.giocatore_id === ADMIN),
"l'admin di prova è tra i destinatari",
);
const mia = righe.find((r) => r.giocatore_id === ADMIN)!;
assert.equal(mia.titolo, `Presenza modificata: ${nomeCompleto}`);
assert.equal(
mia.corpo,
[
`Evento: ${ev.titolo}`,
`Data: ${dataIt}, ore ${ev.ora}`,
"Da: nessuna risposta",
"A: presente",
].join("\n"),
);
},
);
await prova("il giocatore semplice non riceve nulla, l'allenatore sì", async () => {
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(GIOCATORE));
assert.ok(destinatari.includes(ALLENATORE), "l'allenatore attivo è tra i destinatari");
assert.ok(!destinatari.includes(RISPONDE), "chi risponde non avvisa se stesso");
});
await prova("la riga ha la coda email e la coda push", async () => {
const [mia] = (await notifiche()).filter((r) => r.giocatore_id === ADMIN);
const email = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
);
assert.equal(email.length, 1, "email in coda");
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
);
assert.equal(push.length, 1, "push in coda");
});
await prova("modifica: ogni cambio è una notifica a sé, con Da e A giusti", async () => {
const prima = (await notifiche()).length;
await aggiorna(`${PREFISSO}partita`, "ritardo");
const righe = await notifiche();
assert.equal(righe.length, prima + (await attesi()).length);
const ultima = righe.filter((r) => r.giocatore_id === ADMIN).at(-1)!;
assert.match(ultima.corpo, /\nDa: presente\nA: in ritardo$/);
});
await prova("stesso stato di prima: nessun avviso", async () => {
const prima = (await notifiche()).length;
await aggiorna(`${PREFISSO}partita`, "ritardo");
assert.equal((await notifiche()).length, prima);
});
await prova("ritiro della risposta: «nessuna risposta» come stato finale", async () => {
await ritira(`${PREFISSO}partita`);
const ultima = (await notifiche()).filter((r) => r.giocatore_id === ADMIN).at(-1)!;
assert.match(ultima.corpo, /\nDa: in ritardo\nA: nessuna risposta$/);
});
await prova("la presenza dell'admin non avvisa l'admin stesso", async () => {
await svuota();
await scrivi("risposte_presenze", "POST", {
evento_id: `${PREFISSO}partita`,
giocatore_id: ADMIN,
stato: "assente",
});
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(ADMIN));
await scrivi(
`risposte_presenze?evento_id=eq.${PREFISSO}partita&giocatore_id=eq.${ADMIN}`,
"DELETE",
);
});
// --- Casi in cui non deve partire nulla --------------------------------------------------
await prova("allenamento a 5 ore: avvisa", async () => {
await svuota();
await creaEvento(`${PREFISSO}allenamento`, "allenamento", 5);
idEventi.push(`${PREFISSO}allenamento`);
await risposta(`${PREFISSO}allenamento`, "assente");
assert.equal((await notifiche()).length, (await attesi()).length);
});
for (const [nome, tipo, ore] of [
["oltre le 6 ore", "partita", 7],
["già iniziato", "partita", -1],
["un tipo che non è partita né allenamento", "evento", 3],
] as const) {
await prova(`nessun avviso: ${nome}`, async () => {
await svuota();
const id = `${PREFISSO}no-${idEventi.length}`;
await creaEvento(id, tipo, ore);
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length, 0);
});
}
await prova("cancellare l'evento elimina le risposte senza avvisare nessuno", async () => {
await svuota();
const id = `${PREFISSO}cancellato`;
await creaEvento(id, "partita", 2);
idEventi.push(id);
await risposta(id, "presente");
await svuota();
await scrivi(`eventi_app?id=eq.${id}`, "DELETE");
assert.equal((await notifiche()).length, 0);
});
await prova(
"destinatari: né inattivi né senza slot, e una sola notifica a chi ha due ruoli",
async () => {
await svuota();
const id = `${PREFISSO}destinatari`;
await creaEvento(id, "partita", 4);
idEventi.push(id);
await risposta(id, "presente");
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
assert.ok(!destinatari.includes(INATTIVO), "slot disattivato");
assert.deepEqual([...destinatari].sort(), await attesi());
assert.equal(
destinatari.filter((d) => d === ADMIN).length,
1,
"chi ha sia il ruolo admin sia allenatore riceve una sola notifica",
);
},
);
await prova(
"cambio di un giocatore: avvisati admin e allenatore, mai il giocatore stesso",
async () => {
await svuota();
const id = `${PREFISSO}per-conto`;
await creaEvento(id, "partita", 2);
idEventi.push(id);
await risposta(id, "presente");
await aggiorna(id, "assente");
const destinatari = new Set((await notifiche()).map((r) => r.giocatore_id));
assert.ok(destinatari.has(ALLENATORE) && destinatari.has(ADMIN));
assert.ok(!destinatari.has(RISPONDE));
},
);
await prova("tutti gli stati hanno il loro nome nel testo", async () => {
await svuota();
const id = `${PREFISSO}stati`;
await creaEvento(id, "allenamento", 1);
idEventi.push(id);
const sequenza = ["presente", "assente", "forse", "ritardo", "infortunato"];
await risposta(id, sequenza[0]!);
for (const stato of sequenza.slice(1)) await aggiorna(id, stato);
const corpi = (await notifiche())
.filter((r) => r.giocatore_id === ADMIN)
.map((r) => r.corpo.split("\n").slice(2).join("|"));
assert.deepEqual(corpi, [
"Da: nessuna risposta|A: presente",
"Da: presente|A: assente",
"Da: assente|A: forse",
"Da: forse|A: in ritardo",
"Da: in ritardo|A: infortunato",
]);
});
// Confini della finestra: la soglia è di 6 ore esatte (da 0 a 6).
for (const [nome, ore, avvisa] of [
["a 5 ore e 50 minuti", 5 + 50 / 60, true],
["a 6 ore e 10 minuti", 6 + 10 / 60, false],
["a 10 minuti", 10 / 60, true],
["iniziato da 10 minuti", -10 / 60, false],
] as const) {
await prova(`finestra: evento ${nome} ${avvisa ? "avvisa" : "non avvisa"}`, async () => {
await svuota();
const id = `${PREFISSO}soglia-${idEventi.length}`;
await creaEvento(id, "partita", ore);
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length > 0, avvisa);
});
}
await prova("ora dell'evento illeggibile: nessun avviso e nessun errore", async () => {
await svuota();
const id = `${PREFISSO}ora-illeggibile`;
const { data } = traOre(3);
await scrivi("eventi_app", "POST", {
id,
tipo: "partita",
titolo: "Ora strana",
data,
ora: "boh",
});
idEventi.push(id);
await risposta(id, "presente");
assert.equal((await notifiche()).length, 0);
});
await prova("la funzione non è chiamabile dai client", async () => {
for (const token of [ANON, admin.token]) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/avvisa_presenza_modificata`, {
method: "POST",
headers: {
apikey: ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: "{}",
});
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterla eseguire`);
}
});
} finally {
await svuota();
for (const id of idEventi) {
await rest(`risposte_presenze?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
}
if (tokenAdmin) {
for (const [id, valori] of originali) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify(valori),
});
}
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("presenza-modificata");
}
}
@@ -1,204 +0,0 @@
/**
* Badge segreto Mai un forfait end-to-end contro il database locale:
* `bun test/integration/s-mai-forfait-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece
* si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su
* `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso
* entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a
* combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali).
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { serieConferme, contaPresenzeGiocatore } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
if (!locale) {
salta("badge Mai un forfait sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-mai-forfait-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Mai un forfait su ${URL_BASE}`);
const PREFISSO = "test-s-mai-forfait-badge";
const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: `${dataEvento(i)}T08:00:00Z`,
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, i: number) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato: "presente",
risposto_il: `${dataEvento(i)}T08:30:00Z`,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenzeETempi(): Promise<{
presenze: MappaPresenze;
tempi: Record<string, Record<string, string>>;
}> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: Stato;
risposto_il: string;
}>;
const presenze: MappaPresenze = {};
const tempi: Record<string, Record<string, string>> = {};
for (const r of righe) {
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
(tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
}
return { presenze, tempi };
}
function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
presenze,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
};
}
try {
await prova(
"il segreto si sblocca solo quando entrambe le condizioni sono vere insieme",
async () => {
// "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa
// ampiamente sia la serie di conferme (>=10) sia le presenze (>=15).
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "mf1", i);
}
const eventi15 = await leggiEventi();
const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi();
// Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia.
const eventi9 = eventi15.slice(0, 9);
const serie9 = serieConferme("mf1", eventi9, t15, OGGI);
const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI);
assert.equal(serie9, 9);
assert.equal(pres9, 9);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado,
null,
"9 conferme e 9 presenze: nessuna delle due soglie raggiunta",
);
const serie15 = serieConferme("mf1", eventi15, t15, OGGI);
const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI);
assert.equal(serie15, 15);
assert.equal(pres15, 15);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado,
"oro",
"serie conferme >=10 e presenze >=15: il segreto si sblocca",
);
},
);
await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => {
// Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0,
// ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve
// ripartire da zero anche sulle presenze.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: idLento,
giocatore_id: "mf1",
stato: "presente",
risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione
}),
});
if (!res.ok)
throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
const eventi = await leggiEventi();
const { presenze, tempi } = await leggiPresenzeETempi();
const serie = serieConferme("mf1", eventi, tempi, OGGI);
const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI);
assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme");
assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie");
assert.equal(
statoBadge(def, giocatoreAzzerato(serie, conto)).grado,
null,
"presenze abbondanti ma serie azzerata: il segreto si richiude",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-mai-forfait-badge");
}
+2 -2
View File
@@ -254,8 +254,8 @@ if (!locale) {
// --- presenze: la risposta si cambia, il cronometro no -------------------------- // --- presenze: la risposta si cambia, il cronometro no --------------------------
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA // Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non // risposta, `aggiornato_il` è l'ultima modifica; oggi nessuna statistica le usa (la serie
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma // «Conferme 24h» è stata rimossa). Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento. // la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
await prova( await prova(
"la risposta di presenza si aggiorna senza far ripartire il cronometro", "la risposta di presenza si aggiorna senza far ripartire il cronometro",
@@ -1,229 +0,0 @@
/**
* Badge Risposta lampo end-to-end contro il database locale:
* `bun test/integration/serie-conferme-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte
* vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST
* con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso
* `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia
* nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` si rompe, qui il grado
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
* mai il database.
*
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
* - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una
* striscia lunga;
* - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { serieConferme } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Risposta lampo sul database", "stack locale non attivo (npx supabase start)");
riepilogo("serie-conferme-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Risposta lampo su ${URL_BASE}`);
const PREFISSO = "test-serie-conferme-badge";
const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
// restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
/** Istante di convocazione: le 8 del mattino del giorno dell'evento. */
const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`;
async function creaEvento(
id: string,
i: number,
tipo: "allenamento" | "partita" = "allenamento",
) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo,
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: creazioneEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(
eventoId: string,
giocatoreId: string,
stato: string,
rispostoIl: string,
) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato,
risposto_il: rispostoIl,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */
async function leggiTempi(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
risposto_il: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
return mappa;
}
function giocatoreAzzerato(serieConferme: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera",
async () => {
// 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1":
// bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati.
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`);
}
const eventi15 = await leggiEventi();
const tempi15 = await leggiTempi();
const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI);
assert.equal(serieA3, 3);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
"bronzo",
"3 conferme rapide di fila: soglia bronzo",
);
const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI);
assert.equal(serieA8, 8);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado,
"argento",
"8 conferme rapide di fila: soglia argento",
);
const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI);
assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
"oro",
"15 conferme rapide di fila: soglia oro",
);
// Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`);
const eventiConRitardo = await leggiEventi();
const tempiConRitardo = await leggiTempi();
const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI);
assert.equal(
serieDopoRitardo,
0,
"una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila",
);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado,
null,
"serie azzerata: nessun grado, si riparte da zero",
);
},
);
await prova(
"partite e allenamenti contano nella stessa serie, senza filtro per tipo",
async () => {
// "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro
// allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo.
await creaEvento(`${PREFISSO}-b01`, 30, "allenamento");
await creaEvento(`${PREFISSO}-b02`, 31, "partita");
await creaEvento(`${PREFISSO}-b03`, 32, "allenamento");
await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`);
const eventiLetti = await leggiEventi();
const tempiLetti = await leggiTempi();
const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI);
assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
"bronzo",
"3 conferme rapide di fila, tipo misto: soglia bronzo",
);
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("serie-conferme-badge");
}
-42
View File
@@ -34,7 +34,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
...valori, ...valori,
}; };
} }
@@ -146,16 +145,6 @@ assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro");
assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro");
// --- serie-conferme: soglie 3 / 8 / 15 ----------------------------------------
const serieConfermeDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
assert.equal(gradoRaggiunto(serieConfermeDef, 2), null, "sotto la prima soglia nessun grado");
assert.equal(gradoRaggiunto(serieConfermeDef, 3), "bronzo", "la soglia è inclusiva");
assert.equal(gradoRaggiunto(serieConfermeDef, 7), "bronzo");
assert.equal(gradoRaggiunto(serieConfermeDef, 8), "argento");
assert.equal(gradoRaggiunto(serieConfermeDef, 14), "argento");
assert.equal(gradoRaggiunto(serieConfermeDef, 15), "oro");
assert.equal(gradoRaggiunto(serieConfermeDef, 50), "oro", "oltre l'oro resta oro");
// --- badgeGiocatore ---------------------------------------------------------- // --- badgeGiocatore ----------------------------------------------------------
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
assert.ok( assert.ok(
@@ -200,36 +189,6 @@ assert.deepEqual(
["s-cacche"], ["s-cacche"],
); );
assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano"); assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano");
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
["s-mai-forfait"],
);
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 14 })).length,
0,
"servono entrambe le condizioni",
);
// Confini isolati: ogni soglia testata da sola, con l'altra abbondantemente sopra.
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 9, presenze: 30 })).length,
0,
"serieConferme appena sotto soglia, presenze abbondanti: non basta",
);
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 30 })).map((b) => b.def.id),
["s-mai-forfait"],
"serieConferme esattamente al confine, presenze abbondanti: sblocca",
);
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 14 })).length,
0,
"presenze appena sotto soglia, serieConferme abbondante: non basta",
);
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 15 })).map((b) => b.def.id),
["s-mai-forfait"],
"presenze esattamente al confine, serieConferme abbondante: sblocca",
);
// --- collezioneBadge --------------------------------------------------------- // --- collezioneBadge ---------------------------------------------------------
const vuota = collezioneBadge(g()); const vuota = collezioneBadge(g());
@@ -262,7 +221,6 @@ assert.equal(
palloni: 10, palloni: 10,
presenze: 30, presenze: 30,
serieAllenamenti: 10, serieAllenamenti: 10,
serieConferme: 15,
}), }),
), ),
null, null,
+49
View File
@@ -7,6 +7,9 @@ import {
convocatiEvento, convocatiEvento,
daCategoria, daCategoria,
daRiga, daRiga,
eConvocato,
eventoVisibile,
rosaConvocata,
eventoVuoto, eventoVuoto,
type RigaEvento, type RigaEvento,
} from "@/lib/eventi"; } from "@/lib/eventi";
@@ -123,3 +126,49 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
assert.notEqual(bozza.id, "", "ogni bozza ha un id"); assert.notEqual(bozza.id, "", "ogni bozza ha un id");
console.log("eventi: ok"); console.log("eventi: ok");
// --- eventoVisibile: i non convocati non vedono l'evento, chi gestisce sì -----
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g1", false), true, "il convocato lo vede");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", false), false, "il non convocato no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, null, false), false, "senza slot no");
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", true), true, "admin/allenatore sempre");
assert.equal(eventoVisibile({ convocati: [] }, "g2", false), true, "vuoto = tutta la rosa");
// --- eConvocato: elenco vuoto = tutti ----------------------------------------
assert.equal(eConvocato({ convocati: ["g1"] }, "g1"), true, "convocato");
assert.equal(eConvocato({ convocati: ["g1"] }, "g2"), false, "non convocato");
assert.equal(eConvocato({ convocati: [] }, "g2"), true, "vuoto = tutti");
// --- Copertura per numero di convocati: 1, 2, 3, tutti, nessuno, id sconosciuti -
const squadra = ["g1", "g2", "g3", "g4"].map((id) => ({ id }));
const ids = (l: Array<{ id: string }>) => l.map((g) => g.id);
const casi: Array<{ nome: string; convocati: string[]; attesi: string[] }> = [
{ nome: "nessuno indicato = tutta la rosa", convocati: [], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "solo 1", convocati: ["g2"], attesi: ["g2"] },
{ nome: "2", convocati: ["g1", "g4"], attesi: ["g1", "g4"] },
{ nome: "3", convocati: ["g3", "g1", "g2"], attesi: ["g1", "g2", "g3"] },
{ nome: "tutti indicati", convocati: ["g1", "g2", "g3", "g4"], attesi: ["g1", "g2", "g3", "g4"] },
{ nome: "id sconosciuto ignorato", convocati: ["g2", "zz"], attesi: ["g2"] },
{ nome: "solo ids sconosciuti: nessuno", convocati: ["zz"], attesi: [] },
];
for (const { nome, convocati, attesi } of casi) {
assert.deepEqual(ids(rosaConvocata(squadra, convocati)), attesi, `rosaConvocata: ${nome}`);
const conv = { convocati };
for (const { id } of squadra) {
const dentro = attesi.includes(id) || convocati.length === 0;
assert.equal(
eConvocato(conv, id),
convocati.length === 0 || convocati.includes(id),
`eConvocato ${id}: ${nome}`,
);
assert.equal(eventoVisibile(conv, id, false), dentro, `visibile a ${id}: ${nome}`);
assert.equal(
eventoVisibile(conv, id, true),
true,
`admin/allenatore vede sempre (${id}): ${nome}`,
);
}
assert.equal(eventoVisibile(conv, null, false), convocati.length === 0, `senza slot: ${nome}`);
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
}
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
+1 -2
View File
@@ -19,7 +19,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
...valori, ...valori,
}; };
} }
@@ -57,7 +56,7 @@ assert.ok(
// --- serie: solo sul traguardo esatto ---------------------------------------- // --- serie: solo sul traguardo esatto ----------------------------------------
assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3")); assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3"));
assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo"); assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo");
assert.ok(ids(g({ serieConferme: 8 })).includes("serie:conferme:8")); assert.ok(ids(g({ seriePartite: 5 })).includes("serie:partite:5"));
// --- obiettivi di squadra: uno solo, tra il 90% e il 99% -------------------- // --- obiettivi di squadra: uno solo, tra il 90% e il 99% --------------------
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
+1
View File
@@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [
"sollecita_presenze_6h", "sollecita_presenze_6h",
"compleanno", "compleanno",
"compleanno_auguri", "compleanno_auguri",
"presenza_modificata",
]; ];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) { for (const tipo of tipiNuovi) {
+33 -84
View File
@@ -7,7 +7,6 @@ import {
contaPresenzeGiocatore, contaPresenzeGiocatore,
destinatariSollecito, destinatariSollecito,
includeInfortunato, includeInfortunato,
serieConferme,
serieConsecutiva, serieConsecutiva,
testoSollecito, testoSollecito,
totaliEventiGiocatore, totaliEventiGiocatore,
@@ -218,91 +217,9 @@ assert.ok(
"senza luogo la riga manca", "senza luogo la riga manca",
); );
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
...ev(id, "allenamento", data),
...(creatoIl === undefined ? {} : { creatoIl }),
});
const conConvocazione: Evento[] = [
convocati("c1", "2026-08-06", "2026-08-01T10:00:00Z"),
convocati("c2", "2026-08-13", "2026-08-08T10:00:00Z"),
convocati("c3", "2026-08-20", "2026-08-15T10:00:00Z"),
convocati("c4", "2026-08-27"), // evento generato dal client: nessuna convocazione tracciata
convocati("c5", "2026-09-10", "2026-09-05T10:00:00Z"), // futuro
];
const tempi: MappaTempiRisposta = {
c1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo
c2: { g1: "2026-08-10T10:00:00Z" }, // due giorni dopo: buco
c3: { g1: "2026-08-16T09:59:00Z" }, // appena dentro le 24h
c5: { g1: "2026-09-05T10:30:00Z" },
};
assert.equal(serieConferme("g1", conConvocazione, tempi, OGGI), 1, "il ritardo su c2 azzera");
assert.equal(
serieConferme("g1", conConvocazione.slice(0, 1), tempi, OGGI),
1,
"una risposta rapida vale 1",
);
assert.equal(
serieConferme("g1", [conConvocazione[0]!, conConvocazione[3]!, conConvocazione[2]!], tempi, OGGI),
2,
"un evento senza istante di convocazione viene saltato, non spezza la serie",
);
assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero");
// Chi non è convocato non spezza la serie conferme di nessun altro (stesso filtro condiviso
// con `serieConsecutiva`, vedi sopra).
const c6Ristretto: Evento = {
...convocati("c6", "2026-08-08", "2026-08-03T10:00:00Z"),
convocati: ["g9"],
};
assert.equal(
serieConferme("g1", [conConvocazione[0]!, c6Ristretto], tempi, OGGI),
1,
"un evento convocato solo per un altro giocatore resta fuori, la serie di g1 non ne risente",
);
// Le conferme contano partite e allenamenti insieme: nessuna versione per tipo.
const mistiTipo: Evento[] = [
convocati("m1", "2026-08-06", "2026-08-01T10:00:00Z"),
{ ...convocati("m2", "2026-08-13", "2026-08-08T10:00:00Z"), tipo: "partita" },
];
const tempiMisti: MappaTempiRisposta = {
m1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo
m2: { g1: "2026-08-08T11:00:00Z" }, // un'ora dopo, ma è una partita
};
assert.equal(
serieConferme("g1", mistiTipo, tempiMisti, OGGI),
2,
"partite e allenamenti si sommano nella stessa serie",
);
// Il confronto con le 24h è inclusivo: esattamente al bordo conta, un secondo oltre azzera.
const bordo: Evento[] = [convocati("b1", "2026-08-06", "2026-08-01T10:00:00Z")];
assert.equal(
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:00Z" } }, OGGI),
1,
"esattamente 24h dopo: il confronto è <=, quindi conta",
);
assert.equal(
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:01Z" } }, OGGI),
0,
"un secondo oltre le 24h azzera",
);
// Un evento futuro non conta ancora, anche con una risposta rapidissima già registrata.
const futuro: Evento[] = [convocati("f1", "2026-09-10", "2026-09-05T10:00:00Z")];
assert.equal(
serieConferme("g1", futuro, { f1: { g1: "2026-09-05T10:05:00Z" } }, OGGI),
0,
"l'evento di domani non è ancora passato, non entra nel calcolo",
);
// --- cache locale dopo una risposta: il cronometro non riparte ---------------- // --- cache locale dopo una risposta: il cronometro non riparte ----------------
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela. // Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
// Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh. // Qui la cache deve imitarla, altrimenti la cache mente fino al refresh.
const PRIMA = "2026-08-01T10:00:00Z"; const PRIMA = "2026-08-01T10:00:00Z";
const POI = "2026-08-08T10:00:00Z"; const POI = "2026-08-08T10:00:00Z";
@@ -377,3 +294,35 @@ assert.equal(
); );
console.log("presenze: ok"); console.log("presenze: ok");
// --- Solleciti per numero di convocati: 1, 2, 3, tutti ------------------------
{
const sq = (["g1", "g2", "g3", "g4"] as const).map((id) => ({
id,
attivo: true,
tipo: "giocatore" as const,
}));
const nessuna: Array<{ giocatore_id: string; stato: string }> = [];
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g2"]), ["g2"], "solo 1 convocato");
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g1", "g3"]), ["g1", "g3"], "2 convocati");
assert.deepEqual(
destinatariSollecito(sq, nessuna, ["g1", "g2", "g4"]),
["g1", "g2", "g4"],
"3 convocati",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g1", stato: "presente" }], ["g1", "g3"]),
["g3"],
"il convocato che ha già risposto non va sollecitato",
);
assert.deepEqual(
destinatariSollecito(sq, [{ giocatore_id: "g4", stato: "presente" }], ["g1"]),
["g1"],
"chi ha risposto ma non è convocato non cambia i destinatari",
);
assert.deepEqual(
destinatariSollecito(sq, nessuna, []),
["g1", "g2", "g3", "g4"],
"nessuno = tutti",
);
}
+6 -6
View File
@@ -4,7 +4,7 @@ import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie"; import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
function g(valori: Partial<Giocatore> = {}): Giocatore { function g(valori: Partial<Giocatore> = {}): Giocatore {
return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, serieConferme: 0, ...valori }; return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, ...valori };
} }
const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!; const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!;
@@ -45,16 +45,16 @@ assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso],
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!"); assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
// --- serieGiocatore / serieMigliore ------------------------------------------ // --- serieGiocatore / serieMigliore ------------------------------------------
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 6 }));
assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti"); assert.equal(tutte.length, serieDefs.length, "due serie indipendenti");
assert.deepEqual( assert.deepEqual(
tutte.map((s) => s.valore), tutte.map((s) => s.valore),
[4, 1, 9], [4, 6],
"ogni serie legge il proprio contatore", "ogni serie legge il proprio contatore",
); );
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 6 }));
assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga"); assert.equal(migliore.def.tipo, "partite", "in home si mostra la serie più lunga");
assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare"); assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare");
// --- invarianti sulle definizioni -------------------------------------------- // --- invarianti sulle definizioni --------------------------------------------