Compare commits
1
Commits
6b8cc33512
..
v1.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49351e536 |
No files matched your search
+3
-4
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 06/10/2026
|
Ultimo aggiornamento: 01/10/2026
|
||||||
|
|
||||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
||||||
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||||
@@ -14,9 +14,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
|||||||
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
|
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
|
||||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||||
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
|
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
|
||||||
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
|
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
|
||||||
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
|
|
||||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||||
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
||||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||||
|
|||||||
+1
-19
@@ -13,33 +13,15 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
### Aggiunto
|
|
||||||
|
|
||||||
- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri
|
|
||||||
attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo
|
|
||||||
messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration
|
|
||||||
`m31_notifica_compleanno`, da applicare in produzione.
|
|
||||||
|
|
||||||
### Modificato
|
|
||||||
|
|
||||||
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
|
|
||||||
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
|
|
||||||
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
|
|
||||||
rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. Nessuna migration.
|
|
||||||
|
|
||||||
## [1.2.3] - 2026-10-04
|
## [1.2.3] - 2026-10-04
|
||||||
|
|
||||||
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
|
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration.
|
||||||
|
|
||||||
### Modificato
|
### Modificato
|
||||||
|
|
||||||
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
|
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
|
||||||
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
|
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
|
||||||
|
|
||||||
### Sicurezza
|
|
||||||
|
|
||||||
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
|
|
||||||
|
|
||||||
## [1.2.2] - 2026-10-01
|
## [1.2.2] - 2026-10-01
|
||||||
|
|
||||||
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
|
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
|
||||||
|
|||||||
+11
-13
@@ -22,7 +22,6 @@ sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011
|
|||||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
|
||||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||||
@@ -122,11 +121,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
|||||||
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
||||||
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||||
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
- Il `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||||
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
||||||
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
||||||
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
||||||
automatici (compleanni compresi) ma non quelli dei pulsanti.
|
automatici ma non quelli dei pulsanti.
|
||||||
|
|
||||||
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
||||||
|
|
||||||
@@ -152,16 +151,15 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
|
|||||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||||
|
|
||||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||||
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
| `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. | — |
|
||||||
| `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. | — |
|
||||||
| `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). | — |
|
||||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
|
||||||
|
|
||||||
## Badge
|
## Badge
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
|||||||
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
|
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
|
||||||
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||||
|
|
||||||
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
|
|
||||||
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
|
|
||||||
|
|
||||||
## Prossimo
|
## Prossimo
|
||||||
|
|
||||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# DD-047 — Notifica di compleanno
|
|
||||||
|
|
||||||
**Data:** 6 ottobre 2026
|
|
||||||
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
|
|
||||||
|
|
||||||
**Contesto**
|
|
||||||
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
|
|
||||||
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
|
|
||||||
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
|
|
||||||
|
|
||||||
**Decisione**
|
|
||||||
|
|
||||||
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
|
|
||||||
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
|
|
||||||
`notifiche_utente`, il worker `mailer/` manda push ed email.
|
|
||||||
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
|
|
||||||
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
|
|
||||||
mancato si recupera all'ora dopo e non si genera mai due volte.
|
|
||||||
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
|
|
||||||
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
|
|
||||||
nessun avviso.
|
|
||||||
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
|
|
||||||
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
|
|
||||||
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
|
|
||||||
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
|
|
||||||
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
|
|
||||||
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
|
|
||||||
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
|
|
||||||
senza i nomi degli altri.
|
|
||||||
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
|
|
||||||
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
|
|
||||||
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
|
|
||||||
`eventi_app`.
|
|
||||||
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
|
|
||||||
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
|
|
||||||
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
|
|
||||||
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
|
|
||||||
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
|
|
||||||
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
|
|
||||||
|
|
||||||
**Alternative scartate**
|
|
||||||
|
|
||||||
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
|
|
||||||
chiede la issue.
|
|
||||||
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
|
|
||||||
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
|
|
||||||
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
|
|
||||||
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
|
|
||||||
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
|
|
||||||
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
|
|
||||||
|
|
||||||
**Conseguenze**
|
|
||||||
|
|
||||||
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
|
|
||||||
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
|
|
||||||
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
|
|
||||||
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
|
|
||||||
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
|
|
||||||
destinatari). Chi la inserisce prima delle 8 viene incluso.
|
|
||||||
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
|
|
||||||
- Il compleanno resta anche in Calendario, invariato.
|
|
||||||
|
|
||||||
**Riesame**
|
|
||||||
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
|
|
||||||
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
|
|
||||||
@@ -86,7 +86,6 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
|||||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
|
||||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
| [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-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
|
|||||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
||||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||||
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
|
||||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||||
|
|
||||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
|||||||
|
|
||||||
## Quali notifiche e a chi
|
## Quali notifiche e a chi
|
||||||
|
|
||||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla
|
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||||
|
|||||||
+21
-32
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
|||||||
indipendenti:
|
indipendenti:
|
||||||
|
|
||||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||||
presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||||
|
|
||||||
@@ -34,20 +34,19 @@ in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
|||||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
||||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||||
|
|
||||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||||
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
||||||
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
||||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||||
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
|
||||||
|
|
||||||
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) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||||
automatiche (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||||
|
|
||||||
@@ -64,17 +63,15 @@ volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato d
|
|||||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
||||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||||
|
|
||||||
| # | Notifica | Titolo | Corpo |
|
| # | Notifica | Titolo | Corpo |
|
||||||
| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
| 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 |
|
|
||||||
|
|
||||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||||
@@ -106,7 +103,6 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
|
|||||||
- Assenze e infortuni.
|
- Assenze e infortuni.
|
||||||
- La registrazione o il collegamento di un nuovo giocatore.
|
- La registrazione o il collegamento di un nuovo giocatore.
|
||||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||||
- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047).
|
|
||||||
|
|
||||||
### Comportamenti da conoscere
|
### Comportamenti da conoscere
|
||||||
|
|
||||||
@@ -147,12 +143,6 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
|
|||||||
- **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
|
- **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
|
||||||
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||||
- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e
|
|
||||||
agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se
|
|
||||||
il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario.
|
|
||||||
Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro.
|
|
||||||
- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli
|
|
||||||
altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato.
|
|
||||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||||
|
|
||||||
@@ -166,7 +156,6 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
|
|||||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
|
||||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.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`) |
|
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||||
|
|
||||||
|
|||||||
@@ -37,22 +37,22 @@ corrente dinamico (vedi sotto).
|
|||||||
|
|
||||||
## Obiettivi definiti
|
## Obiettivi definiti
|
||||||
|
|
||||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||||
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
|
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||||
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
|
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
|
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||||
|
|
||||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||||
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
|
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||||
@@ -67,18 +67,6 @@ automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla da
|
|||||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||||
seguono di conseguenza.
|
seguono di conseguenza.
|
||||||
|
|
||||||
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
|
|
||||||
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
|
|
||||||
`adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a
|
|
||||||
0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il
|
|
||||||
passaggio a completato compare al refresh successivo.
|
|
||||||
|
|
||||||
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
|
|
||||||
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
|
|
||||||
conferme). Differisce da `o1` per periodo (stagione, non mese) e perché i posti sono i convocati
|
|
||||||
di ciascun evento, non sempre l'intera rosa. Non c'è un confine di stagione esplicito: conta ciò
|
|
||||||
che c'è in `eventi_app`.
|
|
||||||
|
|
||||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||||
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
||||||
@@ -169,8 +157,8 @@ da un backend reale, non solo funzione pura con contesto costruito a mano).
|
|||||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||||
(evento dentro/fuori mese), scadenza dinamica.
|
(evento dentro/fuori mese), scadenza dinamica.
|
||||||
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
|
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||||
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
|
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||||
|
|||||||
Generated
+2046
-249
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -20,10 +20,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.104.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"@tanstack/react-router": "^1.170.41",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
"@tanstack/react-start": "^1.168.60",
|
"@tanstack/react-start": "^1.168.32",
|
||||||
"@tanstack/router-plugin": "^1.168.42",
|
"@tanstack/router-plugin": "^1.168.23",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ export type TipoNotifica =
|
|||||||
| "sollecita_presenze_24h"
|
| "sollecita_presenze_24h"
|
||||||
| "sollecita_presenze_12h"
|
| "sollecita_presenze_12h"
|
||||||
| "sollecita_presenze_6h"
|
| "sollecita_presenze_6h"
|
||||||
| "sondaggio_cacche"
|
| "sondaggio_cacche";
|
||||||
| "compleanno"
|
|
||||||
| "compleanno_auguri";
|
|
||||||
|
|
||||||
export type NotificaUtente = {
|
export type NotificaUtente = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+7
-51
@@ -31,24 +31,6 @@ function meseCorrente(oggi: Date): string {
|
|||||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Istante corrente in Europe/Rome come "YYYY-MM-DD HH:mm", confrontabile con data + ora di un evento. */
|
|
||||||
function adessoRoma(oggi: Date): string {
|
|
||||||
const p = Object.fromEntries(
|
|
||||||
new Intl.DateTimeFormat("en-CA", {
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
})
|
|
||||||
.formatToParts(oggi)
|
|
||||||
.map((x) => [x.type, x.value]),
|
|
||||||
);
|
|
||||||
return `${p["year"]}-${p["month"]}-${p["day"]} ${p["hour"]}:${p["minute"]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
||||||
function aMese(oggi: Date): string {
|
function aMese(oggi: Date): string {
|
||||||
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
||||||
@@ -80,27 +62,6 @@ function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese:
|
|||||||
return Math.round((presenti / posti) * 100);
|
return Math.round((presenti / posti) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Presenze (presente o ritardo) su tutti gli allenamenti e le partite definiti, passati e
|
|
||||||
* futuri, sul totale dei posti: per ogni evento i convocati, o tutta la rosa se non ce ne sono.
|
|
||||||
*/
|
|
||||||
function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) {
|
|
||||||
let posti = 0;
|
|
||||||
let presenti = 0;
|
|
||||||
for (const e of ctx.eventi) {
|
|
||||||
if (e.tipo !== "partita" && e.tipo !== "allenamento") continue;
|
|
||||||
const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id));
|
|
||||||
posti += attesi.length;
|
|
||||||
const risposte = ctx.presenze[e.id] ?? {};
|
|
||||||
presenti += attesi.filter((g) => {
|
|
||||||
const stato = risposte[g.id];
|
|
||||||
return stato === "presente" || stato === "ritardo";
|
|
||||||
}).length;
|
|
||||||
}
|
|
||||||
if (posti === 0) return 0;
|
|
||||||
return Math.round((presenti / posti) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
|
function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
|
||||||
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
|
const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno");
|
||||||
if (daRispondere.length === 0 || rosaSize === 0) return 0;
|
if (daRispondere.length === 0 || rosaSize === 0) return 0;
|
||||||
@@ -118,6 +79,7 @@ export function obiettiviSquadra(
|
|||||||
ctx: ContestoObiettivi = contestoVuoto,
|
ctx: ContestoObiettivi = contestoVuoto,
|
||||||
oggi: Date = new Date(),
|
oggi: Date = new Date(),
|
||||||
): ObiettivoSquadra[] {
|
): ObiettivoSquadra[] {
|
||||||
|
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||||
const vittorie = ctx.vittorie ?? 0;
|
const vittorie = ctx.vittorie ?? 0;
|
||||||
const mese = meseCorrente(oggi);
|
const mese = meseCorrente(oggi);
|
||||||
@@ -146,11 +108,11 @@ export function obiettiviSquadra(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "o7",
|
id: "o7",
|
||||||
titolo: "Presenze collettive",
|
titolo: "250 presenze complessive",
|
||||||
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
|
descrizione: "Somma delle presenze di tutta la rosa in stagione",
|
||||||
valore: percentualePresenzeCollettive(ctx, rosa),
|
valore: somma((g) => g.presenze),
|
||||||
target: 90,
|
target: 250,
|
||||||
unita: "%",
|
unita: "presenze",
|
||||||
emoji: "🤝",
|
emoji: "🤝",
|
||||||
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
|
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
|
||||||
},
|
},
|
||||||
@@ -218,13 +180,7 @@ export function obiettiviSquadra(
|
|||||||
id: "o6",
|
id: "o6",
|
||||||
titolo: "1 evento di squadra al mese",
|
titolo: "1 evento di squadra al mese",
|
||||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||||
// Conta solo quando l'ora dell'evento è arrivata, non quando viene definito.
|
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
||||||
valore: ctx.eventi.filter(
|
|
||||||
(e) =>
|
|
||||||
e.tipo === "evento" &&
|
|
||||||
e.data.startsWith(mese) &&
|
|
||||||
`${e.data} ${e.ora || "00:00"}` <= adessoRoma(oggi),
|
|
||||||
).length,
|
|
||||||
target: 1,
|
target: 1,
|
||||||
unita: "eventi",
|
unita: "eventi",
|
||||||
emoji: "🍕",
|
emoji: "🍕",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function NotFoundComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
|
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
-- M31 — Notifica di compleanno (DD-047, issue #11)
|
|
||||||
--
|
|
||||||
-- Ogni giorno, dalle 8:00 (fuso Europe/Rome), chi ha il compleanno riceve il solo titolo di auguri e tutti gli
|
|
||||||
-- altri membri attivi della squadra (allenatori compresi) ricevono il solo avviso «Oggi è il compleanno
|
|
||||||
-- di ...», senza corpo e senza età.
|
|
||||||
-- Se in un giorno compiono gli anni più persone, ogni destinatario riceve un solo messaggio: chi non
|
|
||||||
-- festeggia con tutti i nomi, ogni festeggiato solo i propri auguri.
|
|
||||||
--
|
|
||||||
-- Canali come le altre notifiche automatiche (DD-038): la riga in `notifiche_utente` genera la mail
|
|
||||||
-- (il trigger di M26 lascia passare i nuovi tipi) e, con M31, anche la push dalla coda.
|
|
||||||
--
|
|
||||||
-- 29 febbraio: il confronto è tra giorno e mese, quindi chi è nato il 29 febbraio viene avvisato solo
|
|
||||||
-- negli anni bisestili. Limite noto e accettato (DD-047): non c'è un ripiego al 28 febbraio.
|
|
||||||
|
|
||||||
-- --- Tipi 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'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- --- Registro ----------------------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Una riga per destinatario e giorno: ricorda che l'avviso di quel giorno è già stato generato,
|
|
||||||
-- anche se il giocatore elimina la notifica (come `promemoria_eventi_generati`, DD-037). Non è legato
|
|
||||||
-- a un evento: i compleanni non sono `eventi_app`. Sparisce con il giocatore (DD-029).
|
|
||||||
|
|
||||||
CREATE TABLE public.compleanni_notificati (
|
|
||||||
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
|
||||||
giorno date NOT NULL,
|
|
||||||
generato_il timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (giocatore_id, giorno)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE public.compleanni_notificati IS
|
|
||||||
'Avvisi di compleanno già generati per destinatario e giorno: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M31, DD-047).';
|
|
||||||
|
|
||||||
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
|
|
||||||
ALTER TABLE public.compleanni_notificati ENABLE ROW LEVEL SECURITY;
|
|
||||||
REVOKE ALL ON public.compleanni_notificati FROM PUBLIC, anon, authenticated;
|
|
||||||
GRANT ALL ON public.compleanni_notificati TO service_role;
|
|
||||||
|
|
||||||
-- --- Job ---------------------------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Gira ogni ora e agisce solo dalle 8:00 in poi, ora locale: un giro mancato si recupera all'ora
|
|
||||||
-- dopo e il registro impedisce di generare due volte lo stesso avviso. `pg_cron` ragiona in UTC e
|
|
||||||
-- l'ora legale sposta l'ora locale di una ora: per questo l'ora si legge qui, in `Europe/Rome`.
|
|
||||||
--
|
|
||||||
-- Il registro è per destinatario e giorno: chi entra in squadra o inserisce la data di nascita dopo
|
|
||||||
-- che gli avvisi del giorno sono partiti non genera un secondo giro (DD-047).
|
|
||||||
--
|
|
||||||
-- `p_adesso` esiste per i test, che simulano un'altra data; il job lo chiama senza argomenti.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.genera_avvisi_compleanno(p_adesso timestamptz DEFAULT now())
|
|
||||||
RETURNS void
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = public
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
adesso timestamp := p_adesso AT TIME ZONE 'Europe/Rome';
|
|
||||||
oggi date := (p_adesso AT TIME ZONE 'Europe/Rome')::date;
|
|
||||||
BEGIN
|
|
||||||
IF extract(hour FROM adesso) < 8 THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
WITH festeggiati AS (
|
|
||||||
SELECT g.id, g.nome
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.attivo = true
|
|
||||||
AND g.nascita IS NOT NULL
|
|
||||||
AND extract(month FROM g.nascita) = extract(month FROM oggi)
|
|
||||||
AND extract(day FROM g.nascita) = extract(day FROM oggi)
|
|
||||||
),
|
|
||||||
elenco AS (
|
|
||||||
SELECT array_agg(nome ORDER BY nome, id COLLATE "C") AS nomi
|
|
||||||
FROM festeggiati
|
|
||||||
),
|
|
||||||
nuovi AS (
|
|
||||||
INSERT INTO public.compleanni_notificati (giocatore_id, giorno)
|
|
||||||
SELECT g.id, oggi
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.attivo = true
|
|
||||||
AND EXISTS (SELECT 1 FROM festeggiati)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
RETURNING giocatore_id
|
|
||||||
)
|
|
||||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
|
|
||||||
SELECT n.giocatore_id, 'compleanno_auguri',
|
|
||||||
'Buon compleanno, ' || f.nome || '! 🎂',
|
|
||||||
''
|
|
||||||
FROM nuovi n
|
|
||||||
JOIN festeggiati f ON f.id = n.giocatore_id
|
|
||||||
UNION ALL
|
|
||||||
SELECT n.giocatore_id, 'compleanno',
|
|
||||||
'Oggi è il compleanno di '
|
|
||||||
|| CASE WHEN cardinality(e.nomi) = 1 THEN e.nomi[1]
|
|
||||||
ELSE array_to_string(e.nomi[1:cardinality(e.nomi) - 1], ', ')
|
|
||||||
|| ' e ' || e.nomi[cardinality(e.nomi)]
|
|
||||||
END,
|
|
||||||
''
|
|
||||||
FROM nuovi n
|
|
||||||
CROSS JOIN elenco e
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM festeggiati f WHERE f.id = n.giocatore_id);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
REVOKE ALL ON FUNCTION public.genera_avvisi_compleanno(timestamptz) FROM PUBLIC, anon, authenticated;
|
|
||||||
GRANT EXECUTE ON FUNCTION public.genera_avvisi_compleanno(timestamptz) TO service_role;
|
|
||||||
|
|
||||||
SELECT cron.schedule(
|
|
||||||
'avvisi-compleanno',
|
|
||||||
'0 * * * *',
|
|
||||||
$$ SELECT public.genera_avvisi_compleanno() $$
|
|
||||||
);
|
|
||||||
|
|
||||||
-- --- Push: anche i compleanni ------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Nascono nel database come le altre notifiche automatiche: nessuna route le manda, quindi la push
|
|
||||||
-- passa dalla coda (DD-038). Stesso trigger di M26 con i due tipi nuovi.
|
|
||||||
|
|
||||||
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'
|
|
||||||
))
|
|
||||||
EXECUTE FUNCTION public.accoda_push_promemoria();
|
|
||||||
+6
-6
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
|||||||
|
|
||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
| Cartella | Cosa verifica | Serve rete? |
|
| Cartella | Cosa verifica | Serve rete? |
|
||||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
||||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||||
|
|
||||||
## Server di sviluppo con Node vecchio
|
## Server di sviluppo con Node vecchio
|
||||||
|
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notifica di compleanno (M31, DD-047, issue #11): `bun test/integration/notifiche-compleanno.test.ts`.
|
|
||||||
*
|
|
||||||
* Esegue il job vero del database (`genera_avvisi_compleanno`) simulando date diverse con il
|
|
||||||
* parametro `p_adesso`, e verifica:
|
|
||||||
*
|
|
||||||
* - l'ora: prima delle 8:00 (fuso Europe/Rome, anche con l'ora legale) non parte nulla;
|
|
||||||
* - un compleanno: auguri al festeggiato, avviso con il solo nome a tutti gli altri membri attivi
|
|
||||||
* (allenatore compreso), niente a chi non è attivo;
|
|
||||||
* - più compleanni lo stesso giorno: un solo messaggio per destinatario, con i nomi in ordine
|
|
||||||
* alfabetico («A e B», «A, B e C»); ogni festeggiato riceve solo i propri auguri;
|
|
||||||
* - una sola generazione per destinatario e giorno, anche se la notifica viene eliminata;
|
|
||||||
* - il canale: push ed email in coda;
|
|
||||||
* - il 29 febbraio: avvisato solo negli anni bisestili (limite noto, DD-047);
|
|
||||||
* - che il job sia pianificato ogni ora.
|
|
||||||
*
|
|
||||||
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4`, `g5`, `g6`, `g9` (inattivo) e
|
|
||||||
* `g10` (allenatore) della rosa seed, ripristinati alla fine, e date simulate nel 2027/2028 in cui
|
|
||||||
* nessun altro slot compie gli anni (lo verifica il test stesso).
|
|
||||||
*/
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { statoLocale } from "../helpers/locale";
|
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
|
||||||
|
|
||||||
const locale = statoLocale();
|
|
||||||
|
|
||||||
if (!locale) {
|
|
||||||
salta("notifiche di compleanno", "stack locale non attivo (npx supabase start)");
|
|
||||||
riepilogo("notifiche-compleanno");
|
|
||||||
} else {
|
|
||||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
|
||||||
console.log(`notifiche di compleanno su ${URL_BASE}`);
|
|
||||||
|
|
||||||
const PASSWORD = "prova-notifiche-compleanno-123";
|
|
||||||
const G4 = "g4";
|
|
||||||
const G5 = "g5";
|
|
||||||
const G6 = "g6";
|
|
||||||
const INATTIVO = "g9";
|
|
||||||
const ALLENATORE = "g10";
|
|
||||||
const TIPI = "compleanno,compleanno_auguri";
|
|
||||||
|
|
||||||
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()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Il job, come se «adesso» fosse `quando` (istante UTC). */
|
|
||||||
async function gira(quando: string) {
|
|
||||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: SERVIZIO,
|
|
||||||
Authorization: `Bearer ${SERVIZIO}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_adesso: quando }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`genera_avvisi_compleanno: ${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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
type Slot = { id: string; nome: string; nascita: string | null; attivo: boolean };
|
|
||||||
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
|
|
||||||
|
|
||||||
const slot = () => leggi<Slot>("giocatori_squadra?select=id,nome,nascita,attivo&order=id");
|
|
||||||
const notifiche = () =>
|
|
||||||
leggi<Riga>(
|
|
||||||
`notifiche_utente?tipo=in.(${TIPI})&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id`,
|
|
||||||
);
|
|
||||||
const registro = (giorno: string) =>
|
|
||||||
leggi<{ giocatore_id: string }>(
|
|
||||||
`compleanni_notificati?giorno=eq.${giorno}&select=giocatore_id&order=giocatore_id`,
|
|
||||||
);
|
|
||||||
const impostaNascita = (id: string, nascita: string | null) =>
|
|
||||||
scrivi(`giocatori_squadra?id=eq.${id}`, "PATCH", { nascita }, tokenAdmin);
|
|
||||||
const svuota = async () => {
|
|
||||||
await rest(`notifiche_utente?tipo=in.(${TIPI})`, SERVIZIO, { method: "DELETE" });
|
|
||||||
await rest("compleanni_notificati?giorno=gte.2000-01-01", SERVIZIO, { method: "DELETE" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Chi compie gli anni nel giorno `iso` secondo il database: attivo e con lo stesso giorno e mese. */
|
|
||||||
const festeggiatiDi = async (iso: string) =>
|
|
||||||
(await slot())
|
|
||||||
.filter((s) => s.attivo && s.nascita?.slice(5) === iso.slice(5))
|
|
||||||
.map((s) => s.id)
|
|
||||||
.sort();
|
|
||||||
const attivi = async () =>
|
|
||||||
(await slot())
|
|
||||||
.filter((s) => s.attivo)
|
|
||||||
.map((s) => s.id)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
let tokenAdmin = "";
|
|
||||||
const idUtenti: string[] = [];
|
|
||||||
const originali = new Map<string, Record<string, unknown>>();
|
|
||||||
|
|
||||||
// Orari UTC corrispondenti a 07:30 e 08:30 a Roma: l'inverno è UTC+1, l'estate UTC+2.
|
|
||||||
const INVERNO = { prima: "T06:30:00Z", dopo: "T07:30:00Z" };
|
|
||||||
const ESTATE = { prima: "T05:30:00Z", dopo: "T06:30:00Z" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const admin = await creaUtente(`test-compleanno-admin-${Date.now()}@example.test`);
|
|
||||||
const allenatore = await creaUtente(`test-compleanno-allenatore-${Date.now()}@example.test`);
|
|
||||||
idUtenti.push(admin.id, allenatore.id);
|
|
||||||
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
|
|
||||||
tokenAdmin = admin.token;
|
|
||||||
for (const id of [G4, G5, G6, INATTIVO, ALLENATORE]) {
|
|
||||||
const [riga] = await leggi<Record<string, unknown>>(
|
|
||||||
`giocatori_squadra?id=eq.${id}&select=nascita,tipo,attivo,auth_user_id`,
|
|
||||||
);
|
|
||||||
originali.set(id, riga ?? { nascita: null, tipo: "giocatore", attivo: true });
|
|
||||||
}
|
|
||||||
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
|
|
||||||
await scrivi(
|
|
||||||
`giocatori_squadra?id=eq.${ALLENATORE}`,
|
|
||||||
"PATCH",
|
|
||||||
{ tipo: "allenatore", auth_user_id: allenatore.id },
|
|
||||||
tokenAdmin,
|
|
||||||
);
|
|
||||||
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
|
|
||||||
// Nessuno dei cinque slot di prova parte con una nascita che possa confondere i casi.
|
|
||||||
for (const id of [G4, G5, G6, INATTIVO]) await impostaNascita(id, null);
|
|
||||||
await svuota();
|
|
||||||
|
|
||||||
const tutti = await slot();
|
|
||||||
const nome = (id: string) => tutti.find((s) => s.id === id)!.nome;
|
|
||||||
const attiviIds = await attivi();
|
|
||||||
assert.ok(attiviIds.includes(ALLENATORE), "l'allenatore è attivo");
|
|
||||||
assert.ok(!attiviIds.includes(INATTIVO), "g9 è inattivo");
|
|
||||||
|
|
||||||
// --- Un compleanno ---------------------------------------------------------------------
|
|
||||||
// 15 marzo 2027, orario invernale: 07:30 a Roma è prima, 08:30 è dopo.
|
|
||||||
const GIORNO1 = "2027-03-15";
|
|
||||||
await impostaNascita(G4, "1995-03-15");
|
|
||||||
|
|
||||||
await prova("prima delle 8:00 a Roma non parte nulla, il registro resta vuoto", async () => {
|
|
||||||
assert.deepEqual(await festeggiatiDi(GIORNO1), [G4], "solo g4 compie gli anni quel giorno");
|
|
||||||
await gira(`${GIORNO1}${INVERNO.prima}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
assert.equal((await registro(GIORNO1)).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova(
|
|
||||||
"dalle 8:00: auguri al festeggiato, avviso con il solo nome a tutti gli altri",
|
|
||||||
async () => {
|
|
||||||
await gira(`${GIORNO1}${INVERNO.dopo}`);
|
|
||||||
const righe = await notifiche();
|
|
||||||
const mie = righe.filter((r) => r.giocatore_id === G4);
|
|
||||||
assert.equal(mie.length, 1);
|
|
||||||
assert.equal(mie[0]!.tipo, "compleanno_auguri");
|
|
||||||
assert.equal(mie[0]!.titolo, `Buon compleanno, ${nome(G4)}! 🎂`);
|
|
||||||
assert.equal(mie[0]!.corpo, "", "solo il titolo");
|
|
||||||
|
|
||||||
const altri = righe.filter((r) => r.giocatore_id !== G4);
|
|
||||||
assert.deepEqual(
|
|
||||||
altri.map((r) => r.giocatore_id).sort(),
|
|
||||||
attiviIds.filter((id) => id !== G4),
|
|
||||||
"tutti gli altri membri attivi, allenatore compreso, e nessun inattivo",
|
|
||||||
);
|
|
||||||
for (const r of altri) {
|
|
||||||
assert.equal(r.tipo, "compleanno");
|
|
||||||
assert.equal(r.titolo, `Oggi è il compleanno di ${nome(G4)}`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo, niente età");
|
|
||||||
}
|
|
||||||
assert.equal(righe.length, attiviIds.length, "una notifica a testa");
|
|
||||||
assert.deepEqual(
|
|
||||||
(await registro(GIORNO1)).map((r) => r.giocatore_id),
|
|
||||||
attiviIds,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("l'allenatore riceve l'avviso, l'inattivo niente", async () => {
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.filter((r) => r.giocatore_id === ALLENATORE).length, 1);
|
|
||||||
assert.equal(righe.filter((r) => r.giocatore_id === INATTIVO).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("push ed email vanno in coda per ogni notifica", async () => {
|
|
||||||
const ids = (await notifiche()).map((r) => r.id);
|
|
||||||
const mail = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
|
||||||
);
|
|
||||||
const push = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
|
||||||
);
|
|
||||||
assert.equal(mail.length, ids.length, "una mail per notifica");
|
|
||||||
assert.equal(push.length, ids.length, "una push per notifica");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un secondo giro lo stesso giorno non genera doppioni", async () => {
|
|
||||||
await gira(`${GIORNO1}T09:30:00Z`);
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("eliminare la notifica non la fa tornare", async () => {
|
|
||||||
const [prima] = await notifiche();
|
|
||||||
await scrivi(`notifiche_utente?id=eq.${prima!.id}`, "DELETE");
|
|
||||||
await gira(`${GIORNO1}T10:30:00Z`);
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length - 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un giorno senza compleanni non genera nulla, né registro", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-20";
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [], "nessuno compie gli anni il 20 marzo");
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
assert.equal((await registro(giorno)).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Ora legale ------------------------------------------------------------------------
|
|
||||||
await prova("con l'ora legale le 8:00 di Roma sono le 6:00 UTC", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-07-15";
|
|
||||||
await impostaNascita(G4, "1995-07-15");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4]);
|
|
||||||
await gira(`${giorno}${ESTATE.prima}`); // 07:30 a Roma
|
|
||||||
assert.equal((await notifiche()).length, 0, "alle 7:30 a Roma non parte ancora");
|
|
||||||
await gira(`${giorno}${ESTATE.dopo}`); // 08:30 a Roma
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Più compleanni lo stesso giorno ---------------------------------------------------
|
|
||||||
await prova(
|
|
||||||
"due compleanni: un solo messaggio a testa, nomi in ordine alfabetico",
|
|
||||||
async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-16";
|
|
||||||
await impostaNascita(G4, "1995-03-16");
|
|
||||||
await impostaNascita(G5, "1990-03-16");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5]);
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.length, attiviIds.length, "un solo messaggio per destinatario");
|
|
||||||
const nomi = [nome(G4), nome(G5)].sort((a, b) => a.localeCompare(b, "it"));
|
|
||||||
for (const id of [G4, G5]) {
|
|
||||||
const r = righe.find((x) => x.giocatore_id === id)!;
|
|
||||||
assert.equal(r.tipo, "compleanno_auguri");
|
|
||||||
assert.equal(r.titolo, `Buon compleanno, ${nome(id)}! 🎂`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo");
|
|
||||||
assert.ok(!r.titolo.includes(nome(id === G4 ? G5 : G4)), "niente nomi degli altri");
|
|
||||||
}
|
|
||||||
const altri = righe.filter((r) => ![G4, G5].includes(r.giocatore_id));
|
|
||||||
assert.equal(altri.length, attiviIds.length - 2);
|
|
||||||
for (const r of altri) {
|
|
||||||
assert.equal(r.tipo, "compleanno");
|
|
||||||
assert.equal(r.titolo, `Oggi è il compleanno di ${nomi[0]} e ${nomi[1]}`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("tre compleanni: «A, B e C»", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-17";
|
|
||||||
await impostaNascita(G4, "1995-03-17");
|
|
||||||
await impostaNascita(G5, "1990-03-17");
|
|
||||||
await impostaNascita(G6, "2000-03-17");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5, G6]);
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
const nomi = [nome(G4), nome(G5), nome(G6)].sort((a, b) => a.localeCompare(b, "it"));
|
|
||||||
const altro = (await notifiche()).find((r) => ![G4, G5, G6].includes(r.giocatore_id))!;
|
|
||||||
assert.equal(altro.titolo, `Oggi è il compleanno di ${nomi[0]}, ${nomi[1]} e ${nomi[2]}`);
|
|
||||||
assert.equal(altro.corpo, "", "solo il titolo");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un festeggiato inattivo non conta: né avviso né auguri", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-18";
|
|
||||||
for (const id of [G4, G5, G6]) await impostaNascita(id, null);
|
|
||||||
await impostaNascita(INATTIVO, "2003-03-18");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [], "g9 è inattivo");
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 29 febbraio: limite noto (DD-047) -------------------------------------------------
|
|
||||||
await prova("chi è nato il 29 febbraio è avvisato solo negli anni bisestili", async () => {
|
|
||||||
await svuota();
|
|
||||||
await impostaNascita(INATTIVO, null);
|
|
||||||
await impostaNascita(G4, "2000-02-29");
|
|
||||||
// 2027 non è bisestile: né il 28 febbraio né il 1° marzo.
|
|
||||||
await gira(`2027-02-28${INVERNO.dopo}`);
|
|
||||||
await gira(`2027-03-01${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0, "negli anni normali non parte nulla");
|
|
||||||
// 2028 è bisestile: il 29 febbraio sì.
|
|
||||||
assert.deepEqual(await festeggiatiDi("2028-02-29"), [G4]);
|
|
||||||
await gira(`2028-02-29${INVERNO.dopo}`);
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.length, attiviIds.length);
|
|
||||||
assert.equal(righe.find((r) => r.giocatore_id === G4)!.tipo, "compleanno_auguri");
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Pianificazione --------------------------------------------------------------------
|
|
||||||
await prova("il job è pianificato ogni ora", async () => {
|
|
||||||
const contenitore = spawnSync(
|
|
||||||
"docker",
|
|
||||||
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
)
|
|
||||||
.stdout.trim()
|
|
||||||
.split("\n")[0];
|
|
||||||
assert.ok(contenitore, "container del database locale non trovato");
|
|
||||||
const esito = spawnSync(
|
|
||||||
"docker",
|
|
||||||
[
|
|
||||||
"exec",
|
|
||||||
contenitore,
|
|
||||||
"psql",
|
|
||||||
"-U",
|
|
||||||
"postgres",
|
|
||||||
"-At",
|
|
||||||
"-c",
|
|
||||||
"select schedule from cron.job where jobname = 'avvisi-compleanno'",
|
|
||||||
],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
);
|
|
||||||
assert.equal(esito.status, 0, esito.stderr);
|
|
||||||
assert.equal(esito.stdout.trim(), "0 * * * *");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("il job non è chiamabile dai client", async () => {
|
|
||||||
for (const token of [ANON, admin.token]) {
|
|
||||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: ANON,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterlo eseguire`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await svuota();
|
|
||||||
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("notifiche-compleanno");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
|
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
|
||||||
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
|
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
|
||||||
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
|
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
|
||||||
* su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle
|
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
|
||||||
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
||||||
*
|
*
|
||||||
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { giocatori } from "@/lib/crapp-data";
|
import { giocatori } from "@/lib/crapp-data";
|
||||||
import { obiettiviSquadra } from "@/lib/obiettivi";
|
import { obiettiviSquadra } from "@/lib/obiettivi";
|
||||||
import { serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
@@ -282,51 +282,33 @@ if (!locale) {
|
|||||||
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
||||||
async () => {
|
async () => {
|
||||||
const pizzataId = `${PREFISSO}-pizzata`;
|
const pizzataId = `${PREFISSO}-pizzata`;
|
||||||
const pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
|
const inserita = await rest("eventi_app", {
|
||||||
// OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
|
method: "POST",
|
||||||
for (const [id, giorno] of [
|
body: JSON.stringify({
|
||||||
[pizzataId, "10"],
|
id: pizzataId,
|
||||||
[pizzataFuturaId, "20"],
|
tipo: "evento",
|
||||||
] as const) {
|
titolo: "Test obiettivi o6",
|
||||||
const inserita = await rest("eventi_app", {
|
data: `${MESE_TEST}-20`,
|
||||||
method: "POST",
|
}),
|
||||||
body: JSON.stringify({
|
});
|
||||||
id,
|
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
||||||
tipo: "evento",
|
|
||||||
titolo: "Test obiettivi o6",
|
|
||||||
data: `${MESE_TEST}-${giorno}`,
|
|
||||||
ora: "20:30",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tutti = await leggiEventi();
|
const eventiReali = await leggiEventi();
|
||||||
const o6Di = (ids: string[], oggi: Date) =>
|
const o6InMese = obiettiviSquadra(
|
||||||
obiettiviSquadra(
|
giocatori,
|
||||||
giocatori,
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
{ eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
|
OGGI,
|
||||||
oggi,
|
).find((o) => o.id === "o6")!;
|
||||||
).find((o) => o.id === "o6")!;
|
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
|
||||||
|
|
||||||
|
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
|
||||||
|
const o6MeseDopo = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
|
OGGI_MESE_DOPO,
|
||||||
|
).find((o) => o.id === "o6")!;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
o6Di([pizzataId], OGGI).valore,
|
o6MeseDopo.valore,
|
||||||
1,
|
|
||||||
"l'evento sociale già passato conta, letto dal database",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
o6Di([pizzataFuturaId], OGGI).valore,
|
|
||||||
0,
|
|
||||||
"l'evento sociale futuro non conta finché non arriva la sua ora",
|
|
||||||
);
|
|
||||||
// Il 20 marzo 2099 alle 20:30 Roma (CET, UTC+1) = 19:30Z: da lì conta.
|
|
||||||
assert.equal(
|
|
||||||
o6Di([pizzataFuturaId], new Date("2099-03-20T19:30:00Z")).valore,
|
|
||||||
1,
|
|
||||||
"arrivata l'ora dell'evento conta",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
o6Di([pizzataId], new Date("2099-04-15T10:00:00Z")).valore,
|
|
||||||
0,
|
0,
|
||||||
"lo stesso evento non conta più il mese successivo (si azzera)",
|
"lo stesso evento non conta più il mese successivo (si azzera)",
|
||||||
);
|
);
|
||||||
@@ -404,69 +386,68 @@ if (!locale) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await prova(
|
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
|
||||||
"o7 percentuale di presenze collettive da eventi/risposte reali del database",
|
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
|
||||||
async () => {
|
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
|
||||||
// o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
|
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
|
||||||
// convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
|
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
|
||||||
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||||
const partitaId = `${PREFISSO}-o7-partita`;
|
const partitaId = `${PREFISSO}-o7-partita`;
|
||||||
const [g1, g2, g3] = giocatori;
|
const OGGI_STR = "2099-01-20";
|
||||||
|
|
||||||
for (const [id, tipo, data] of [
|
for (const [id, tipo, data] of [
|
||||||
[allenamentoId, "allenamento", "2099-01-05"],
|
[allenamentoId, "allenamento", "2099-01-05"],
|
||||||
[partitaId, "partita", "2099-01-08"],
|
[partitaId, "partita", "2099-01-08"],
|
||||||
] as const) {
|
] as const) {
|
||||||
const inserito = await rest("eventi_app", {
|
const inserito = await rest("eventi_app", {
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
id,
|
|
||||||
tipo,
|
|
||||||
titolo: `Test obiettivi o7 (${tipo})`,
|
|
||||||
data,
|
|
||||||
convocati: [g1!.id, g2!.id, g3!.id],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// g1 presente a entrambi, g2 presente e in ritardo (il ritardo conta), g3 assente a
|
|
||||||
// entrambi: 4 presenze su 6 posti (3 convocati x 2 eventi) = 67%.
|
|
||||||
const righe = [
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
|
||||||
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
|
||||||
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
|
||||||
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
|
||||||
];
|
|
||||||
const inserite = await rest("risposte_presenze", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(righe),
|
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
|
||||||
});
|
});
|
||||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const eventiReali = (await leggiEventi()).filter(
|
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
|
||||||
(e) => e.id === allenamentoId || e.id === partitaId,
|
// il ritardo conta). g3: assente a entrambi (0 presenze).
|
||||||
);
|
const [g1, g2, g3] = giocatori;
|
||||||
const presenzeReali = {
|
const righe = [
|
||||||
...(await leggiPresenze(allenamentoId)),
|
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
...(await leggiPresenze(partitaId)),
|
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
};
|
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
||||||
|
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
];
|
||||||
|
const inserite = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||||
|
|
||||||
const o7 = obiettiviSquadra([g1!, g2!, g3!], {
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
eventi: eventiReali,
|
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||||
presenze: presenzeReali,
|
);
|
||||||
pagelle: [],
|
const presenzeReali = {
|
||||||
}).find((o) => o.id === "o7")!;
|
...(await leggiPresenze(allenamentoId)),
|
||||||
|
...(await leggiPresenze(partitaId)),
|
||||||
|
};
|
||||||
|
|
||||||
assert.equal(
|
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
|
||||||
o7.valore,
|
...g,
|
||||||
67,
|
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
|
||||||
"4 presenze su 6 posti (il ritardo conta), calcolate dal database",
|
}));
|
||||||
);
|
|
||||||
},
|
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
|
||||||
);
|
eventi: [],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
}).find((o) => o.id === "o7")!;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
o7.valore,
|
||||||
|
4,
|
||||||
|
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
||||||
const matchId = `${PREFISSO}-o12-m1`;
|
const matchId = `${PREFISSO}-o12-m1`;
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ const tipiNuovi: TipoNotifica[] = [
|
|||||||
"sollecita_presenze_24h",
|
"sollecita_presenze_24h",
|
||||||
"sollecita_presenze_12h",
|
"sollecita_presenze_12h",
|
||||||
"sollecita_presenze_6h",
|
"sollecita_presenze_6h",
|
||||||
"compleanno",
|
|
||||||
"compleanno_auguri",
|
|
||||||
];
|
];
|
||||||
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||||
for (const tipo of tipiNuovi) {
|
for (const tipo of tipiNuovi) {
|
||||||
@@ -69,13 +67,6 @@ for (const tipo of tipiNuovi) {
|
|||||||
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
|
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compleanno (DD-047): titolo e basta, il corpo è vuoto e resta vuoto (la riga non lo inventa).
|
|
||||||
for (const tipo of ["compleanno", "compleanno_auguri"] as const) {
|
|
||||||
const n = daRiga({ ...riga, tipo, titolo: "Buon compleanno, Marco! 🎂", corpo: "" });
|
|
||||||
assert.equal(n.tipo, tipo);
|
|
||||||
assert.equal(n.corpo, "", `${tipo}: corpo vuoto`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
|
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
|
||||||
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
|
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
|
||||||
const uiBits = readFileSync(
|
const uiBits = readFileSync(
|
||||||
|
|||||||
+17
-158
@@ -216,7 +216,7 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
|
|
||||||
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
|
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
|
||||||
{
|
{
|
||||||
const OGGI_SETTEMBRE = new Date("2026-09-20T10:00:00Z");
|
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
|
||||||
const pizzataSettembre: ContestoObiettivi = {
|
const pizzataSettembre: ContestoObiettivi = {
|
||||||
eventi: [evento("p1", "2026-09-12", "evento")],
|
eventi: [evento("p1", "2026-09-12", "evento")],
|
||||||
presenze: {},
|
presenze: {},
|
||||||
@@ -233,85 +233,6 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
"lo stesso evento non conta se oggi è agosto",
|
"lo stesso evento non conta se oggi è agosto",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Definito ma non ancora arrivato: non conta finché non scatta l'ora dell'evento.
|
|
||||||
const pizzataFutura: ContestoObiettivi = {
|
|
||||||
eventi: [{ ...evento("pf1", "2026-09-28", "evento"), ora: "20:30" }],
|
|
||||||
presenze: {},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, pizzataFutura, OGGI_SETTEMBRE), "o6").valore,
|
|
||||||
0,
|
|
||||||
"un evento futuro non completa l'obiettivo al momento della definizione",
|
|
||||||
);
|
|
||||||
const serata = { ...evento("ps1", "2026-09-20", "evento"), ora: "20:30" };
|
|
||||||
const conSerata: ContestoObiettivi = { eventi: [serata], presenze: {}, pagelle: [] };
|
|
||||||
// 20/09 10:00Z = 12:00 a Roma (CEST): prima delle 20:30 non conta, dopo sì.
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, conSerata, OGGI_SETTEMBRE), "o6").valore,
|
|
||||||
0,
|
|
||||||
"stesso giorno ma ora non ancora arrivata: non conta",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, conSerata, new Date("2026-09-20T18:30:00Z")), "o6").valore,
|
|
||||||
1,
|
|
||||||
"arrivata l'ora dell'evento (20:30 a Roma): conta",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Senza ora vale 00:00: conta dal giorno stesso, non il giorno prima.
|
|
||||||
const senzaOra: ContestoObiettivi = {
|
|
||||||
eventi: [{ ...evento("so1", "2026-09-20", "evento"), ora: "" }],
|
|
||||||
presenze: {},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, senzaOra, OGGI_SETTEMBRE), "o6").valore,
|
|
||||||
1,
|
|
||||||
"evento senza ora: conta dal giorno stesso",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, senzaOra, new Date("2026-09-19T21:00:00Z")), "o6").valore,
|
|
||||||
0,
|
|
||||||
"evento senza ora: la sera prima (Roma) non conta",
|
|
||||||
);
|
|
||||||
// Ora legale: il 20/09 vale CEST (UTC+2), il 20/12 CET (UTC+1): 20:30 Roma = 18:30Z / 19:30Z.
|
|
||||||
const inverno = { ...evento("iv1", "2026-12-20", "evento"), ora: "20:30" };
|
|
||||||
const ctxInverno: ContestoObiettivi = { eventi: [inverno], presenze: {}, pagelle: [] };
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:29:00Z")), "o6").valore,
|
|
||||||
0,
|
|
||||||
"inverno: 19:29Z = 20:29 a Roma, non ancora",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:30:00Z")), "o6").valore,
|
|
||||||
1,
|
|
||||||
"inverno: 19:30Z = 20:30 a Roma, conta",
|
|
||||||
);
|
|
||||||
// Cambio mese a mezzanotte di Roma: 30/09 23:30Z è già 01/10 a Roma (CEST).
|
|
||||||
const fineSett = { ...evento("fs1", "2026-09-30", "evento"), ora: "20:00" };
|
|
||||||
const ctxFine: ContestoObiettivi = { eventi: [fineSett], presenze: {}, pagelle: [] };
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T21:59:00Z")), "o6").valore,
|
|
||||||
1,
|
|
||||||
"30/09 23:59 a Roma: ancora settembre, conta",
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T22:00:00Z")), "o6").valore,
|
|
||||||
0,
|
|
||||||
"01/10 00:00 a Roma: nuovo mese, si azzera",
|
|
||||||
);
|
|
||||||
// Più eventi: ne basta uno passato; quello futuro non si somma.
|
|
||||||
const misti: ContestoObiettivi = {
|
|
||||||
eventi: [evento("m1", "2026-09-05", "evento"), evento("m2", "2026-09-28", "evento")],
|
|
||||||
presenze: {},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(
|
|
||||||
trova(obiettiviSquadra(giocatori, misti, OGGI_SETTEMBRE), "o6").valore,
|
|
||||||
1,
|
|
||||||
"conta solo l'evento già passato",
|
|
||||||
);
|
|
||||||
|
|
||||||
const allenamentoNelMese: ContestoObiettivi = {
|
const allenamentoNelMese: ContestoObiettivi = {
|
||||||
eventi: [evento("al1", "2026-08-12", "allenamento")],
|
eventi: [evento("al1", "2026-08-12", "allenamento")],
|
||||||
presenze: {},
|
presenze: {},
|
||||||
@@ -324,85 +245,23 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) ---
|
// --- somme sulla rosa --------------------------------------------------------
|
||||||
{
|
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
|
||||||
const [g1, g2, g3, g4] = giocatori;
|
assert.equal(trova(vuoti, "o7").valore, sommaPresenze);
|
||||||
const rosa4 = [g1!, g2!, g3!, g4!];
|
assert.equal(
|
||||||
const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7");
|
trova(obiettiviSquadra([], contestoVuoto), "o7").valore,
|
||||||
|
0,
|
||||||
|
"rosa vuota: nessuna presenza",
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(o7(contestoVuoto).titolo, "Presenze collettive");
|
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula
|
||||||
assert.equal(o7(contestoVuoto).target, 90);
|
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30).
|
||||||
assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN");
|
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! }));
|
||||||
assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0");
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
|
||||||
// Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%.
|
30,
|
||||||
const base: ContestoObiettivi = {
|
"somma di presenze note, indipendente dal roster reale",
|
||||||
eventi: [evento("c1", "2026-07-01", "allenamento"), evento("c2", "2026-12-01", "partita")],
|
);
|
||||||
presenze: {
|
|
||||||
c1: { [g1!.id]: "presente", [g2!.id]: "ritardo", [g3!.id]: "presente", [g4!.id]: "assente" },
|
|
||||||
c2: { [g1!.id]: "presente", [g2!.id]: "assente" },
|
|
||||||
},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(o7(base).valore, 50, "passati e futuri nel denominatore, ritardo conta");
|
|
||||||
|
|
||||||
// Con convocati il denominatore sono solo loro: c1 per g1 e g2 (2 presenti su 2) = 100%.
|
|
||||||
const conConvocati: ContestoObiettivi = {
|
|
||||||
...base,
|
|
||||||
eventi: [{ ...evento("c1", "2026-07-01", "allenamento"), convocati: [g1!.id, g2!.id] }],
|
|
||||||
};
|
|
||||||
assert.equal(o7(conConvocati).valore, 100, "denominatore = convocati");
|
|
||||||
|
|
||||||
// Un non convocato che risponde "presente" non gonfia la percentuale.
|
|
||||||
const nonConvocato: ContestoObiettivi = {
|
|
||||||
...conConvocati,
|
|
||||||
presenze: { c1: { [g1!.id]: "presente", [g3!.id]: "presente" } },
|
|
||||||
};
|
|
||||||
assert.equal(o7(nonConvocato).valore, 50, "i non convocati non contano (1 su 2)");
|
|
||||||
|
|
||||||
// Arrotondamento: 179 presenze su 200 posti = 89,5% -> 90; 178 su 200 = 89%.
|
|
||||||
const rosa100 = Array.from({ length: 100 }, (_, i) => ({ ...g1!, id: `r${i}` }));
|
|
||||||
const rispondi = (presenti: number) =>
|
|
||||||
Object.fromEntries(rosa100.map((g, i) => [g.id, i < presenti ? "presente" : "assente"]));
|
|
||||||
const duePartite = (a: number, b: number): ContestoObiettivi => ({
|
|
||||||
eventi: [evento("t1", "2026-07-01", "allenamento"), evento("t2", "2026-07-02", "allenamento")],
|
|
||||||
presenze: {
|
|
||||||
t1: rispondi(a) as Record<string, "presente" | "assente">,
|
|
||||||
t2: rispondi(b) as Record<string, "presente" | "assente">,
|
|
||||||
},
|
|
||||||
pagelle: [],
|
|
||||||
});
|
|
||||||
assert.equal(o7(duePartite(90, 89), rosa100).valore, 90, "89,5% arrotonda a 90");
|
|
||||||
assert.equal(o7(duePartite(90, 88), rosa100).valore, 89, "89% resta 89");
|
|
||||||
assert.equal(o7(duePartite(100, 100), rosa100).valore, 100);
|
|
||||||
|
|
||||||
// Evento privo di risposte: pesa nel denominatore ma non nel numeratore.
|
|
||||||
const senzaRisposte: ContestoObiettivi = {
|
|
||||||
eventi: [evento("n1", "2026-07-01", "allenamento")],
|
|
||||||
presenze: {},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(o7(senzaRisposte).valore, 0, "nessuna risposta: 0%");
|
|
||||||
|
|
||||||
// Convocati che non sono in rosa: non entrano nei posti.
|
|
||||||
const fuoriRosa: ContestoObiettivi = {
|
|
||||||
eventi: [{ ...evento("f1", "2026-07-01", "allenamento"), convocati: ["sconosciuto"] }],
|
|
||||||
presenze: {},
|
|
||||||
pagelle: [],
|
|
||||||
};
|
|
||||||
assert.equal(o7(fuoriRosa).valore, 0, "nessun posto valido: 0, non NaN");
|
|
||||||
|
|
||||||
// Eventi sociali e compleanni non fanno parte del denominatore.
|
|
||||||
const sociali: ContestoObiettivi = {
|
|
||||||
...base,
|
|
||||||
eventi: [
|
|
||||||
...base.eventi,
|
|
||||||
evento("c3", "2026-07-10", "evento"),
|
|
||||||
evento("c4", "2026-07-11", "compleanno"),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
assert.equal(o7(sociali).valore, 50, "solo allenamenti e partite");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pagelle: VotoPagella[] = [
|
const pagelle: VotoPagella[] = [
|
||||||
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
||||||
|
|||||||
@@ -57,12 +57,6 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
|
|||||||
titolo: "🏐 Palloni",
|
titolo: "🏐 Palloni",
|
||||||
testo: "Sabato tocca a te.",
|
testo: "Sabato tocca a te.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Compleanno (DD-047): solo il titolo. Il corpo vuoto resta vuoto, non diventa il testo di ripiego.
|
|
||||||
data: { json: () => ({ title: "Buon compleanno, Marco! 🎂", body: "" }) },
|
|
||||||
titolo: "Buon compleanno, Marco! 🎂",
|
|
||||||
testo: "",
|
|
||||||
},
|
|
||||||
{ data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
|
{ data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in new issue
Block a user