Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a69bcb3f0d | ||
|
|
19ddf715e6 | ||
|
|
2f583ca97f | ||
|
|
7996dd050e | ||
|
|
5ee148b88b | ||
|
|
8def97731c | ||
|
|
5ca081a6af | ||
|
|
3bf07049e9 | ||
|
|
986098d933 | ||
|
|
274fed93c5 | ||
|
|
f0e6a3a6cc | ||
|
|
daa0dd9c60 | ||
|
|
bfb768fb5a |
No files matched your search
+12
-9
@@ -9,14 +9,18 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
||||
|
||||
## Stato generale
|
||||
|
||||
- **Versione:** 1.3.0 (tag `v1.3.0` su `main`) — notifica di compleanno, obiettivi cliccabili con card di
|
||||
dettaglio, presenze calcolate sui convocati, avvisi in Home ridisegnati, caratteri più grandi, tessera CSI
|
||||
tolta dalla dashboard. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||
- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link
|
||||
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
|
||||
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
|
||||
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
|
||||
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
|
||||
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
|
||||
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
|
||||
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
|
||||
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
|
||||
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
|
||||
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
|
||||
26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
|
||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||
@@ -32,9 +36,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.
|
||||
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
||||
slot lo occupa anche in produzione e va liberato da un admin.
|
||||
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99
|
||||
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
|
||||
presenti.
|
||||
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
|
||||
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
|
||||
|
||||
## Note
|
||||
|
||||
|
||||
@@ -13,6 +13,69 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.4.2] - 2026-10-26
|
||||
|
||||
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
|
||||
26/10/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
|
||||
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
|
||||
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
|
||||
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
|
||||
|
||||
## [1.4.1] - 2026-10-19
|
||||
|
||||
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
|
||||
Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
|
||||
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
|
||||
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
|
||||
|
||||
### Corretto
|
||||
|
||||
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
|
||||
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
|
||||
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
|
||||
vuoto resta «tutta la rosa».
|
||||
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
|
||||
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
|
||||
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
|
||||
eventi a cui non sei convocato.
|
||||
|
||||
## [1.4.0] - 2026-10-09
|
||||
|
||||
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
|
||||
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
|
||||
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
|
||||
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
|
||||
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
|
||||
|
||||
## [1.3.1] - 2026-10-06
|
||||
|
||||
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
|
||||
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
|
||||
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
|
||||
(«Nessun compleanno questo mese»).
|
||||
|
||||
### Rimosso
|
||||
|
||||
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
|
||||
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
|
||||
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
|
||||
|
||||
## [1.3.0] - 2026-10-06
|
||||
|
||||
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
|
||||
|
||||
+20
-19
@@ -74,13 +74,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
||||
|
||||
## Eventi e presenze
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `aggiornato_il` è l'ultima modifica. |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
## Scout
|
||||
|
||||
@@ -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.
|
||||
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||
- Il `CHECK` sul tipo ammette anche `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` restano i tipi dei pulsanti manuali.
|
||||
- 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`)
|
||||
|
||||
@@ -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
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
||||
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Badge
|
||||
|
||||
|
||||
+1
-1
@@ -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. |
|
||||
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
|
||||
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
|
||||
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti, partite, conferme entro 24 ore). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
|
||||
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
|
||||
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
|
||||
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
|
||||
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
|
||||
@@ -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
@@ -46,55 +46,56 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
| ------------------- | ------------------------------------------- |
|
||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](DD-005.md) | Mobile-first |
|
||||
| [DD-006](DD-006.md) | AI solo se utile |
|
||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||
| [DD-022](DD-022.md) | App solo chiara |
|
||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
||||
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
||||
| ID | Titolo |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](DD-005.md) | Mobile-first |
|
||||
| [DD-006](DD-006.md) | AI solo se utile |
|
||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||
| [DD-022](DD-022.md) | App solo chiara |
|
||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
||||
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
|
||||
@@ -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. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
||||
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
|
||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||
|
||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||
|
||||
+28
-34
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
|
||||
|
||||
## Elenco badge
|
||||
|
||||
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||
Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
|
||||
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||
informazioni verso l'interfaccia.
|
||||
@@ -93,7 +93,6 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||
|
||||
@@ -104,13 +103,12 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
|
||||
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||
|
||||
| id | nome | condizione esatta |
|
||||
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
| id | nome | condizione esatta |
|
||||
| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||
|
||||
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||
|
||||
@@ -149,24 +147,22 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
||||
|
||||
### Riepilogo per badge
|
||||
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------- | ----------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -193,11 +189,9 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
|
||||
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
|
||||
rimossi il 2026-10-06.
|
||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||
@@ -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
|
||||
conseguenza.
|
||||
|
||||
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
**Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
||||
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
||||
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||
@@ -238,7 +232,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||
- Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
|
||||
ora che le serie sono calcolate.
|
||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||
|
||||
@@ -24,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:
|
||||
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
|
||||
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
|
||||
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
|
||||
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
|
||||
selezionato.
|
||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
|
||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||
@@ -49,6 +53,13 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
|
||||
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||
`eventi_app`.
|
||||
|
||||
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
|
||||
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
|
||||
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
|
||||
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
|
||||
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
|
||||
luogo, e né la card né le notifiche lo mostrano.
|
||||
|
||||
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||
|
||||
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
+17
-14
@@ -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ì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
||||
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6 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
|
||||
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
|
||||
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.` |
|
||||
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
|
||||
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
|
||||
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
|
||||
|
||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
@@ -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
|
||||
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
|
||||
- Assenze e infortuni.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
@@ -158,17 +160,18 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| Cosa | Test |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
| Cosa | Test |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
||||
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
|
||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||
|
||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Modulo — Serie di presenze
|
||||
|
||||
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||
**Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
|
||||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||||
`src/components/crapp/SerieCard.tsx`
|
||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
||||
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
|
||||
`test/integration/serie-allenamenti-badge.test.ts`
|
||||
|
||||
---
|
||||
|
||||
@@ -19,15 +19,18 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
|
||||
|
||||
---
|
||||
|
||||
## Le tre serie in sintesi
|
||||
## Le due serie in sintesi
|
||||
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | -------------------------------------------- | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
|
||||
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
|
||||
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
|
||||
restano nel database, ma oggi nessuna statistica li usa.
|
||||
|
||||
Esiste un terzo contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||
`src/routes/profilo.tsx`.
|
||||
@@ -67,10 +70,10 @@ il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto
|
||||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | ----------------------- |
|
||||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | -------------------------------------- |
|
||||
| `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||
@@ -147,26 +150,6 @@ Conseguenze da conoscere prima di cambiare qualcosa:
|
||||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||||
|
||||
### `serieConferme()` — conferme entro 24 ore
|
||||
|
||||
```ts
|
||||
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||||
```
|
||||
|
||||
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||||
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||||
|
||||
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||||
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||||
sulla reattività, non sulla presenza.
|
||||
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||||
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||||
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||||
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||||
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||||
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||||
l'istante giusto da confrontare.
|
||||
|
||||
### Lettura e cache
|
||||
|
||||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||||
@@ -263,8 +246,6 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
||||
| Dove | Cosa | Soglie |
|
||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||||
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||||
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||||
|
||||
---
|
||||
@@ -320,8 +301,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
||||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
|
||||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crapp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.2",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
|
||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
@@ -75,7 +75,8 @@ export function EventoCard({
|
||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter(inRosa);
|
||||
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
|
||||
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -187,7 +188,7 @@ export function EventoCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{io ? (
|
||||
{io && eConvocato(evento, io.id) ? (
|
||||
<div
|
||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||
role="group"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import type { EventoTipo } from "@/lib/eventi";
|
||||
import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
@@ -20,9 +20,12 @@ export function RosaPresenze({
|
||||
eventoId,
|
||||
data,
|
||||
tipo = "allenamento",
|
||||
convocati = [],
|
||||
}: {
|
||||
eventoId: string;
|
||||
data: string;
|
||||
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
|
||||
convocati?: string[];
|
||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||
tipo?: EventoTipo;
|
||||
}) {
|
||||
@@ -34,7 +37,8 @@ export function RosaPresenze({
|
||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const anagrafica = useAnagraficaRosa();
|
||||
const rosa = rosaConvocata(anagrafica, convocati);
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
|
||||
@@ -97,7 +101,7 @@ export function RosaPresenze({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{io ? (
|
||||
{io && rosa.some((g) => g.id === io.id) ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
La tua risposta
|
||||
|
||||
@@ -145,6 +145,7 @@ export type Database = {
|
||||
data: string;
|
||||
id: string;
|
||||
luogo: string;
|
||||
link_maps: string | null;
|
||||
note: string;
|
||||
ora: string;
|
||||
pagelle_chiuse: boolean;
|
||||
@@ -160,6 +161,7 @@ export type Database = {
|
||||
data: string;
|
||||
id: string;
|
||||
luogo?: string;
|
||||
link_maps?: string | null;
|
||||
note?: string;
|
||||
ora?: string;
|
||||
pagelle_chiuse?: boolean;
|
||||
@@ -175,6 +177,7 @@ export type Database = {
|
||||
data?: string;
|
||||
id?: string;
|
||||
luogo?: string;
|
||||
link_maps?: string | null;
|
||||
note?: string;
|
||||
ora?: string;
|
||||
pagelle_chiuse?: boolean;
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
Repeat,
|
||||
Lock,
|
||||
Ghost,
|
||||
Zap,
|
||||
Rocket,
|
||||
Anchor,
|
||||
Stethoscope,
|
||||
AlarmClock,
|
||||
ClipboardCheck,
|
||||
@@ -114,15 +112,6 @@ export const badgeDefs: BadgeDef[] = [
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
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. */
|
||||
@@ -141,18 +130,6 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
|
||||
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",
|
||||
nome: "Cliente VIP dell'Infermeria",
|
||||
|
||||
@@ -34,7 +34,6 @@ export type Giocatore = {
|
||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||
serieAllenamenti: number;
|
||||
seriePartite: number;
|
||||
serieConferme: number;
|
||||
/** MVP eletti dalla squadra. */
|
||||
mvp: number;
|
||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||
@@ -109,7 +108,6 @@ export const giocatori: Giocatore[] = rosaCSI
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
votiPagella: 0,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
|
||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||
export async function leggiEventi(): Promise<Evento[]> {
|
||||
|
||||
+44
-3
@@ -9,6 +9,8 @@ export type Evento = {
|
||||
tipo: EventoTipo;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
|
||||
linkMaps?: string | undefined;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string;
|
||||
@@ -30,6 +32,7 @@ export type RigaEvento = {
|
||||
tipo: string;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
link_maps?: string | null;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string | null;
|
||||
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
|
||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
||||
titolo: r.titolo,
|
||||
luogo: r.luogo ?? "",
|
||||
linkMaps: r.link_maps ?? "",
|
||||
data: r.data,
|
||||
ora: r.ora ?? "",
|
||||
note: r.note ?? "",
|
||||
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
|
||||
}
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
|
||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||
@@ -76,6 +80,17 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
|
||||
return { tipo: c, campionato: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Il link è accettato solo se punta a Google Maps (stesso criterio del CHECK su
|
||||
* `eventi_app.link_maps`): è un URL che gli utenti toccano, quindi niente `javascript:` né siti terzi.
|
||||
*/
|
||||
const LINK_MAPS =
|
||||
/^https:\/\/(maps\.app\.goo\.gl\/|goo\.gl\/maps\/|maps\.google\.[a-z.]+\/|(www\.)?google\.[a-z.]+\/maps)\S*$/;
|
||||
|
||||
export function linkMapsValido(url: string): boolean {
|
||||
return url.length <= 500 && LINK_MAPS.test(url);
|
||||
}
|
||||
|
||||
export const EVENTI_KEY = ["eventi"] as const;
|
||||
|
||||
async function fetchEventi(): Promise<Evento[]> {
|
||||
@@ -90,6 +105,25 @@ export function useEventi() {
|
||||
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) {
|
||||
const { eventi, ...resto } = useEventi();
|
||||
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
||||
@@ -105,6 +139,7 @@ export function eventoVuoto(): Evento {
|
||||
tipo: "allenamento",
|
||||
titolo: "",
|
||||
luogo: "",
|
||||
linkMaps: "",
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
ora: "20:30",
|
||||
note: "",
|
||||
@@ -125,6 +160,7 @@ export function useSalvaEvento() {
|
||||
tipo: evento.tipo,
|
||||
titolo: evento.titolo,
|
||||
luogo: evento.luogo,
|
||||
link_maps: evento.linkMaps?.trim() || null,
|
||||
data: evento.data,
|
||||
ora: evento.ora,
|
||||
note: evento.note,
|
||||
@@ -198,6 +234,11 @@ export function compleanniEventi(
|
||||
|
||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
||||
if (!evento || evento.convocati.length === 0) return rosa;
|
||||
return rosa.filter((g) => evento.convocati.includes(g.id));
|
||||
return rosaConvocata(rosa, evento?.convocati ?? []);
|
||||
}
|
||||
|
||||
/** 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));
|
||||
}
|
||||
@@ -26,7 +26,8 @@ export type TipoNotifica =
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche"
|
||||
| "compleanno"
|
||||
| "compleanno_auguri";
|
||||
| "compleanno_auguri"
|
||||
| "presenza_modificata";
|
||||
|
||||
export type NotificaUtente = {
|
||||
id: string;
|
||||
|
||||
+1
-26
@@ -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. */
|
||||
function serieSu(
|
||||
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`
|
||||
* 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
|
||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||
*/
|
||||
|
||||
+2
-15
@@ -12,7 +12,6 @@ import { useEventi } from "./eventi";
|
||||
import {
|
||||
contaPartiteGiocate,
|
||||
contaPresenzeGiocatore,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
totaliEventiGiocatore,
|
||||
useRispostePresenze,
|
||||
@@ -68,7 +67,7 @@ export function useRosa(): Giocatore[] {
|
||||
const { salvati: turniSalvati } = useTurniPalloni();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||
const { presenze: mappaPresenze } = useRispostePresenze();
|
||||
|
||||
const votiMvp = voti.data;
|
||||
|
||||
@@ -94,7 +93,6 @@ export function useRosa(): Giocatore[] {
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
@@ -105,18 +103,7 @@ export function useRosa(): Giocatore[] {
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [
|
||||
squadra,
|
||||
votiMvp,
|
||||
pagelle,
|
||||
cacche,
|
||||
turniSalvati,
|
||||
infortuni,
|
||||
ritardi,
|
||||
eventi,
|
||||
mappaPresenze,
|
||||
tempi,
|
||||
]);
|
||||
}, [squadra, votiMvp, pagelle, cacche, turniSalvati, infortuni, ritardi, eventi, mappaPresenze]);
|
||||
}
|
||||
|
||||
/** Criteri di ordinamento della classifica interna di Squadra. */
|
||||
|
||||
+2
-10
@@ -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 { Giocatore } from "./crapp-data";
|
||||
|
||||
export type SerieTipo = "allenamenti" | "partite" | "conferme";
|
||||
export type SerieTipo = "allenamenti" | "partite";
|
||||
|
||||
export type SerieDef = {
|
||||
tipo: SerieTipo;
|
||||
@@ -31,14 +31,6 @@ export const serieDefs: SerieDef[] = [
|
||||
traguardi: [2, 5, 8, 12],
|
||||
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 = {
|
||||
|
||||
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
@@ -93,7 +103,12 @@ function AllenamentoDetail() {
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
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 { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
@@ -56,7 +57,7 @@ function Calendario() {
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi } = useEventi();
|
||||
const { eventi } = useEventiVisibili();
|
||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `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).
|
||||
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) : [];
|
||||
// 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 (
|
||||
<>
|
||||
@@ -270,13 +278,15 @@ function Calendario() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Compleanni">
|
||||
<Section titolo="Prossimi compleanni">
|
||||
<div className="space-y-3">
|
||||
{compleanniMese.length > 0 ? (
|
||||
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
|
||||
{prossimiCompleanni.length > 0 ? (
|
||||
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">
|
||||
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
|
||||
{compleanniMeseCorrente.length > 0
|
||||
? "Nessun altro compleanno questo mese"
|
||||
: "Nessun compleanno questo mese"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+26
-3
@@ -1,3 +1,4 @@
|
||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||
@@ -21,8 +22,8 @@ import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
eventoVuoto,
|
||||
linkMapsValido,
|
||||
useEliminaEvento,
|
||||
useEventi,
|
||||
useSalvaEvento,
|
||||
type CategoriaEvento,
|
||||
type Evento,
|
||||
@@ -65,7 +66,7 @@ function GestioneEventi() {
|
||||
const io = useGiocatoreBase();
|
||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
@@ -111,6 +112,12 @@ function GestioneEventi() {
|
||||
toast.error("Serve un titolo per l'evento");
|
||||
return;
|
||||
}
|
||||
if ((bozza.linkMaps ?? "").trim() && !linkMapsValido(bozza.linkMaps!.trim())) {
|
||||
toast.error(
|
||||
"Il link deve essere di Google Maps (https://maps.app.goo.gl/… o google.com/maps…)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (modificaEsistente) {
|
||||
setConfermaModifica(true);
|
||||
return;
|
||||
@@ -121,7 +128,11 @@ function GestioneEventi() {
|
||||
async function conferma() {
|
||||
if (!bozza) return;
|
||||
try {
|
||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
||||
await salva.mutateAsync({
|
||||
...bozza,
|
||||
titolo: bozza.titolo.trim(),
|
||||
linkMaps: (bozza.linkMaps ?? "").trim(),
|
||||
});
|
||||
toast.success("Evento salvato");
|
||||
setBozza(null);
|
||||
} catch {
|
||||
@@ -373,6 +384,18 @@ function GestioneEventi() {
|
||||
/>
|
||||
</Campo>
|
||||
|
||||
<Campo label="Link Google Maps (facoltativo)">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={bozza.linkMaps ?? ""}
|
||||
maxLength={500}
|
||||
placeholder="Incolla il link da Condividi → Copia link"
|
||||
onChange={(e) => aggiorna({ linkMaps: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
|
||||
<Campo label="Note">
|
||||
<textarea
|
||||
value={bozza.note}
|
||||
|
||||
@@ -72,6 +72,16 @@ function EventoDetail() {
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
@@ -88,7 +98,12 @@ function EventoDetail() {
|
||||
</Section>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||
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 { useIo, useObiettivi } from "@/lib/rosa";
|
||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
@@ -47,14 +48,14 @@ function Index() {
|
||||
const base = useGiocatoreBase();
|
||||
const giocatore = useIo();
|
||||
const allenatore = isAllenatore(base);
|
||||
const { eventi } = useEventi();
|
||||
const { eventi } = useEventiVisibili();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
||||
const prossimo = prossimi[0] ?? null;
|
||||
const daConfermare = prossimi
|
||||
.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 { data: csi } = useCsi();
|
||||
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||
|
||||
@@ -133,6 +133,16 @@ function PartitaDetail() {
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
@@ -297,7 +307,12 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- M34 — Link Google Maps del luogo di un evento
|
||||
--
|
||||
-- Colonna facoltativa: NULL = nessun link. Il CHECK accetta solo URL https di Google Maps (stesso
|
||||
-- criterio di `linkMapsValido()` in src/lib/eventi.ts), perché il link è mostrato a tutta la squadra
|
||||
-- come pulsante. Chi lo scrive è già limitato ad admin e allenatori dalle policy di `eventi_app` (M11).
|
||||
|
||||
ALTER TABLE public.eventi_app
|
||||
ADD COLUMN link_maps text,
|
||||
ADD CONSTRAINT eventi_app_link_maps_google
|
||||
CHECK (
|
||||
link_maps IS NULL
|
||||
OR (
|
||||
length(link_maps) <= 500
|
||||
AND link_maps ~ '^https://(maps\.app\.goo\.gl/|goo\.gl/maps/|maps\.google\.[a-z.]+/|(www\.)?google\.[a-z.]+/maps)\S*$'
|
||||
)
|
||||
);
|
||||
+6
-6
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
||||
|
||||
## Struttura
|
||||
|
||||
| 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 |
|
||||
| `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ì |
|
||||
| `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 | — |
|
||||
| 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 |
|
||||
| `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ì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
## 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");
|
||||
}
|
||||
@@ -254,8 +254,8 @@ if (!locale) {
|
||||
|
||||
// --- presenze: la risposta si cambia, il cronometro no --------------------------
|
||||
// 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
|
||||
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
|
||||
// risposta, `aggiornato_il` è l'ultima modifica; oggi nessuna statistica le usa (la serie
|
||||
// «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.
|
||||
await prova(
|
||||
"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");
|
||||
}
|
||||
@@ -34,7 +34,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
...valori,
|
||||
};
|
||||
}
|
||||
@@ -146,16 +145,6 @@ assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
|
||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "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 ----------------------------------------------------------
|
||||
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
|
||||
assert.ok(
|
||||
@@ -200,36 +189,6 @@ assert.deepEqual(
|
||||
["s-cacche"],
|
||||
);
|
||||
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 ---------------------------------------------------------
|
||||
const vuota = collezioneBadge(g());
|
||||
@@ -262,7 +221,6 @@ assert.equal(
|
||||
palloni: 10,
|
||||
presenze: 30,
|
||||
serieAllenamenti: 10,
|
||||
serieConferme: 15,
|
||||
}),
|
||||
),
|
||||
null,
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
convocatiEvento,
|
||||
daCategoria,
|
||||
daRiga,
|
||||
eConvocato,
|
||||
eventoVisibile,
|
||||
rosaConvocata,
|
||||
eventoVuoto,
|
||||
linkMapsValido,
|
||||
type RigaEvento,
|
||||
} from "@/lib/eventi";
|
||||
|
||||
@@ -32,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
|
||||
tipo: "partita",
|
||||
titolo: "CRAP - Avversari",
|
||||
luogo: "PalaCRAP",
|
||||
linkMaps: "",
|
||||
data: "2026-09-01",
|
||||
ora: "21:00",
|
||||
note: "portare la seconda maglia",
|
||||
@@ -123,3 +128,78 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
|
||||
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
||||
|
||||
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");
|
||||
|
||||
// Link Google Maps: solo URL https di Google Maps, e vuoto se la riga non ne ha uno.
|
||||
assert.equal(daRiga(riga).linkMaps, "");
|
||||
assert.equal(daRiga({ ...riga, link_maps: null }).linkMaps, "");
|
||||
assert.equal(
|
||||
daRiga({ ...riga, link_maps: "https://maps.app.goo.gl/abc123" }).linkMaps,
|
||||
"https://maps.app.goo.gl/abc123",
|
||||
);
|
||||
assert.equal(eventoVuoto().linkMaps, "");
|
||||
for (const ok of [
|
||||
"https://maps.app.goo.gl/abc123",
|
||||
"https://goo.gl/maps/xyz",
|
||||
"https://www.google.com/maps/place/PalaCRAP",
|
||||
"https://google.it/maps?q=Cesena",
|
||||
"https://maps.google.com/?q=Cesena",
|
||||
]) {
|
||||
assert.equal(linkMapsValido(ok), true, ok);
|
||||
}
|
||||
for (const ko of [
|
||||
"",
|
||||
"javascript:alert(1)",
|
||||
"http://maps.app.goo.gl/abc123",
|
||||
"https://example.com/maps",
|
||||
"https://maps.app.goo.gl.evil.com/abc",
|
||||
"https://www.google.com/search?q=maps",
|
||||
"https://maps.app.goo.gl/a b",
|
||||
]) {
|
||||
assert.equal(linkMapsValido(ko), false, ko);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
...valori,
|
||||
};
|
||||
}
|
||||
@@ -57,7 +56,7 @@ assert.ok(
|
||||
// --- serie: solo sul traguardo esatto ----------------------------------------
|
||||
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({ 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% --------------------
|
||||
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
|
||||
|
||||
@@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [
|
||||
"sollecita_presenze_6h",
|
||||
"compleanno",
|
||||
"compleanno_auguri",
|
||||
"presenza_modificata",
|
||||
];
|
||||
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||
for (const tipo of tipiNuovi) {
|
||||
|
||||
+33
-84
@@ -7,7 +7,6 @@ import {
|
||||
contaPresenzeGiocatore,
|
||||
destinatariSollecito,
|
||||
includeInfortunato,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
testoSollecito,
|
||||
totaliEventiGiocatore,
|
||||
@@ -218,91 +217,9 @@ assert.ok(
|
||||
"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 ----------------
|
||||
// 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 POI = "2026-08-08T10:00:00Z";
|
||||
|
||||
@@ -377,3 +294,35 @@ assert.equal(
|
||||
);
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
|
||||
|
||||
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")!;
|
||||
@@ -45,16 +45,16 @@ assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso],
|
||||
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
|
||||
|
||||
// --- serieGiocatore / serieMigliore ------------------------------------------
|
||||
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
|
||||
assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti");
|
||||
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 6 }));
|
||||
assert.equal(tutte.length, serieDefs.length, "due serie indipendenti");
|
||||
assert.deepEqual(
|
||||
tutte.map((s) => s.valore),
|
||||
[4, 1, 9],
|
||||
[4, 6],
|
||||
"ogni serie legge il proprio contatore",
|
||||
);
|
||||
|
||||
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
|
||||
assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga");
|
||||
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 6 }));
|
||||
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");
|
||||
|
||||
// --- invarianti sulle definizioni --------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user