5 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 6b8cc33512 Obiettivo Presenze collettive: percentuale al 90% su allenamenti e partite definiti
Sostituisce «250 presenze complessive». Il valore è la percentuale di presenze
(presente o ritardo) su tutti gli allenamenti e le partite in calendario, passati
e futuri, con i convocati come posti (tutta la rosa se non ce ne sono). Stagione
intera, nessun reset mensile.

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

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

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

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

Chiude la issue #11.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:18:22 +02:00
davideandClaude Sonnet 5.5 7347fdba6e Avviso certificati mancanti (DD-046), dipendenze aggiornate, versione 1.2.3
Il giocatore senza certificato vede una card rossa cliccabile, l'admin
l'elenco dei mancanti sopra scaduti e in scadenza. Nessuna migration.

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:29:47 +02:00
davideandClaude Sonnet 5.5 008f0d784c Merge develop in main: versione 1.2.2
Avvisi certificati a 30 giorni per il giocatore (DD-041), turno palloni e sollecito presenze una sola volta (DD-042, DD-045), niente promemoria evento (DD-043), niente foto tessera (DD-044). Migration m27-m30 già applicate in produzione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:26 +02:00
28 changed files with 1472 additions and 2266 deletions

No files matched your search

+7 -6
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 01/10/2026 Ultimo aggiornamento: 06/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
@@ -9,13 +9,14 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.2.2 su `develop` (su `main`, in produzione, resta la 1.2.1 fino al merge) — notifiche - **Versione:** 1.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
su tre canali, avvisi di palloni (3 ore prima) e presenze (24 ore prima) una sola volta, niente (3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
promemoria evento, niente foto tessera, avviso certificato a 30 giorni per il giocatore. 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:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte - **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie `m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
`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
+27
View File
@@ -13,6 +13,33 @@ 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
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
### Modificato
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
### Sicurezza
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
## [1.2.2] - 2026-10-01 ## [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
+13 -11
View File
@@ -22,6 +22,7 @@ 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) |
@@ -121,11 +122,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data. - Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e - Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e `sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali. `sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi - Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici ma non quelli dei pulsanti. automatici (compleanni compresi) ma non quelli dei pulsanti.
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`) ### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
@@ -151,15 +152,16 @@ 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) |
| `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. | — | | `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) |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | | `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — | | `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge ## Badge
+6 -3
View File
@@ -38,15 +38,18 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
calcolata dal CSI calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza - [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
stagione e badge ([specifica](modules/allenatore.md), DD-034) stagione e badge ([specifica](modules/allenatore.md), DD-034)
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la - [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035) ([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046)
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati, - [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
([specifica](modules/notifiche-email.md), DD-036) ([specifica](modules/notifiche-email.md), DD-036)
- [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
+3 -2
View File
@@ -1,7 +1,7 @@
# DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin # DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
**Data:** 24 settembre 2026 **Data:** 24 settembre 2026
**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. **Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. [DD-046](DD-046.md) aggiunge l'avviso per chi non ha caricato il certificato.
**Contesto** **Contesto**
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
@@ -40,7 +40,8 @@ certificato viene rinnovato. Specifica completa in
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). ([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)).
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto - Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
dal widget «Completa il tuo profilo» e dalla tab Profili. dal widget «Completa il tuo profilo» e dalla tab Profili. _Superata da [DD-046](DD-046.md):
anche i mancanti hanno un avviso, rosso._
**Conseguenze** **Conseguenze**
+33
View File
@@ -0,0 +1,33 @@
# DD-046 — Avviso certificati: anche chi non l'ha caricato
**Data:** 4 ottobre 2026
**Stato:** Accettata (modifica [DD-035](DD-035.md): il certificato mancante non è più escluso dall'avviso)
**Contesto**
L'avviso certificati di DD-035 guarda solo la scadenza e salta chi non ha mai caricato il
certificato («è un problema diverso»). Per l'admin significa che chi non ha caricato nulla non
compare in Home, pur essendo un caso più grave di uno scaduto.
**Decisione**
1. Un giocatore in rosa **senza data o senza file** del certificato è **mancante**.
2. L'**admin** vede la card rossa «Certificati mancanti» con l'elenco, **sopra** scaduti e in
scadenza; il suo nome non compare se è anche giocatore.
3. Il **giocatore** mancante vede la card rossa «Certificato mancante», cliccabile verso il tab
documenti. Vale anche per l'admin che è giocatore.
4. Nessuna soglia: l'avviso resta finché il certificato non è caricato. Il rosso è `destructive`,
già nel tema; giallo e nero degli altri avvisi non cambiano.
**Alternative scartate**
- Stesso nero degli scaduti → non distingue «scaduto» da «mai caricato», che è più grave.
- Solo elenco admin → l'admin che è anche giocatore sarebbe l'unico a non vedere il proprio buco.
**Conseguenze**
- `avvisiCertificati()` restituisce anche `mancanti`; `avvisiCertificatiUtente()` anche
`personaleMancante`.
- Un giocatore appena entrato in rosa compare tra i mancanti finché non carica il file.
**Riesame**
Se i giocatori nuovi riempiono l'elenco senza motivo, aggiungere un periodo di tolleranza.
+65
View File
@@ -0,0 +1,65 @@
# DD-047 — Notifica di compleanno
**Data:** 6 ottobre 2026
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
**Contesto**
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
**Decisione**
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
`notifiche_utente`, il worker `mailer/` manda push ed email.
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
mancato si recupera all'ora dopo e non si genera mai due volte.
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
nessun avviso.
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
senza i nomi degli altri.
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
`eventi_app`.
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
**Alternative scartate**
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
chiede la issue.
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
**Conseguenze**
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
destinatari). Chi la inserisce prima delle 8 viene incluso.
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
- Il compleanno resta anche in Calendario, invariato.
**Riesame**
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
+2
View File
@@ -86,6 +86,8 @@ 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-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 |
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore | | [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
+1
View File
@@ -132,6 +132,7 @@ 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
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi ## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla **Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **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
+32 -21
View File
@@ -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, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)). presenze, compleanni, 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,19 +34,20 @@ 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) 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 e 8) 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 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei 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
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»).
@@ -63,15 +64,17 @@ 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`,
@@ -103,6 +106,7 @@ 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
@@ -143,6 +147,12 @@ 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.
@@ -156,6 +166,7 @@ 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`) |
+27 -15
View File
@@ -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` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` | | `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | | `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 (dinamico) | 1 | `eventi_app` | | `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il 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 (250 presenze, 200 pagelle, 7.5 di media, 1/5/10 smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o 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,6 +67,18 @@ 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.
@@ -157,8 +169,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 eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la - **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma. li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
- **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
+20 -18
View File
@@ -117,12 +117,12 @@ staff.
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` | | Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` | | Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
| Condizione | Stato | Avviso | | Condizione | Stato | Avviso |
| --------------------- | ----------- | ------------------ | | --------------------- | ----------- | ------------------------------ |
| `giorni > soglia` | valido | nessuno | | `giorni > soglia` | valido | nessuno |
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) | | `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
| `giorni < 0` | scaduto | nero (`primary`) | | `giorni < 0` | scaduto | nero (`primary`) |
| data o file mancanti | mancante | nessuno | | data o file mancanti | mancante | rosso (`destructive`) (DD-046) |
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi. giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
@@ -130,8 +130,10 @@ giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il - Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo. giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo. - L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella - Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello
tab Profili di `/admin` e nel widget di completamento del giocatore. scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti),
l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio
nome. Non ha soglie: resta finché il file non è caricato.
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`, - Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
cioè `attivo` e `tipo = 'giocatore'`). cioè `attivo` e `tipo = 'giocatore'`).
@@ -190,16 +192,16 @@ la Home non deve rompersi per un dato accessorio.
#### Implementazione #### Implementazione
| Pezzo | Ruolo | | Pezzo | Ruolo |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 | | `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) | | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto | | `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` | | `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) |
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) | | `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» | | `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card | | `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` | | `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da - "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima. `new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
+249 -2046
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.2.2", "version": "1.2.3",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
@@ -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.101.1", "@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.18", "@tanstack/react-router": "^1.170.41",
"@tanstack/react-start": "^1.168.32", "@tanstack/react-start": "^1.168.60",
"@tanstack/router-plugin": "^1.168.23", "@tanstack/router-plugin": "^1.168.42",
"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",
+25 -6
View File
@@ -18,7 +18,7 @@ function Card({
righe, righe,
cliccabile, cliccabile,
}: { }: {
colore: "warning" | "primary"; colore: "warning" | "primary" | "destructive";
titolo: string; titolo: string;
righe: string[]; righe: string[];
cliccabile?: boolean; cliccabile?: boolean;
@@ -26,7 +26,9 @@ function Card({
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${ const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning" colore === "warning"
? "bg-warning text-warning-foreground" ? "bg-warning text-warning-foreground"
: "bg-primary text-primary-foreground" : colore === "destructive"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`; } ${cliccabile ? "premi" : ""}`;
const contenuto = ( const contenuto = (
<> <>
@@ -50,7 +52,7 @@ function Card({
} }
/** /**
* Avviso certificati in scadenza/scaduti (DD-035, DD-041): calcolato al volo, niente push. * Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7. * Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
*/ */
export function AvvisoCertificati() { export function AvvisoCertificati() {
@@ -65,8 +67,18 @@ export function AvvisoCertificati() {
const oggi = oggiISO(); const oggi = oggiISO();
// Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041). // Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
const { personale, staff } = avvisiCertificatiUtente(rosa, profili, oggi, { admin, base }); const { personale, personaleMancante, staff } = avvisiCertificatiUtente(rosa, profili, oggi, {
const cardPersonale = personale ? ( admin,
base,
});
const cardPersonale = personaleMancante ? (
<Card
colore="destructive"
titolo="Certificato mancante"
cliccabile
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
/>
) : personale ? (
personale.giorni < 0 ? ( personale.giorni < 0 ? (
<Card <Card
colore="primary" colore="primary"
@@ -87,10 +99,17 @@ export function AvvisoCertificati() {
) : null; ) : null;
if (!staff) return cardPersonale; if (!staff) return cardPersonale;
const { scaduti, inScadenza } = staff; const { scaduti, inScadenza, mancanti } = staff;
return ( return (
<> <>
{cardPersonale} {cardPersonale}
{mancanti.length > 0 ? (
<Card
colore="destructive"
titolo="Certificati mancanti"
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
/>
) : null}
{scaduti.length > 0 ? ( {scaduti.length > 0 ? (
<Card <Card
colore="primary" colore="primary"
+3 -1
View File
@@ -24,7 +24,9 @@ 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;
+51 -7
View File
@@ -31,6 +31,24 @@ 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(
@@ -62,6 +80,27 @@ 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;
@@ -79,7 +118,6 @@ 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);
@@ -108,11 +146,11 @@ export function obiettiviSquadra(
}, },
{ {
id: "o7", id: "o7",
titolo: "250 presenze complessive", titolo: "Presenze collettive",
descrizione: "Somma delle presenze di tutta la rosa in stagione", descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
valore: somma((g) => g.presenze), valore: percentualePresenzeCollettive(ctx, rosa),
target: 250, target: 90,
unita: "presenze", unita: "%",
emoji: "🤝", emoji: "🤝",
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.", impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
}, },
@@ -180,7 +218,13 @@ 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",
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length, // Conta solo quando l'ora dell'evento è arrivata, non quando viene definito.
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: "🍕",
+23 -10
View File
@@ -247,14 +247,17 @@ export type AvvisoCertificato = {
giorni: number; giorni: number;
}; };
/** Giocatore in rosa senza certificato caricato (DD-046). */
export type CertificatoMancante = Pick<AvvisoCertificato, "giocatoreId" | "nome" | "cognome">;
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number { function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome); return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
} }
/** /**
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da * Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha * `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa. Chi non ha
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza). * ancora un certificato caricato non ha una scadenza: finisce in `mancanti` (DD-046).
* `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041). * `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
*/ */
export function avvisiCertificati( export function avvisiCertificati(
@@ -262,13 +265,21 @@ export function avvisiCertificati(
profili: Record<string, Profilo>, profili: Record<string, Profilo>,
oggi: string, oggi: string,
soglia: number = GIORNI_AVVISO_CERTIFICATO, soglia: number = GIORNI_AVVISO_CERTIFICATO,
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } { ): {
scaduti: AvvisoCertificato[];
inScadenza: AvvisoCertificato[];
mancanti: CertificatoMancante[];
} {
const scaduti: AvvisoCertificato[] = []; const scaduti: AvvisoCertificato[] = [];
const inScadenza: AvvisoCertificato[] = []; const inScadenza: AvvisoCertificato[] = [];
const mancanti: CertificatoMancante[] = [];
for (const g of rosa) { for (const g of rosa) {
if (!inRosa(g)) continue; if (!inRosa(g)) continue;
const p = profili[g.id]; const p = profili[g.id];
if (!p?.certificatoScadenza || !p.certificatoPath) continue; if (!p?.certificatoScadenza || !p.certificatoPath) {
mancanti.push({ giocatoreId: g.id, nome: g.nome, cognome: g.cognome });
continue;
}
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi); const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
if (giorni > soglia) continue; if (giorni > soglia) continue;
const avviso: AvvisoCertificato = { const avviso: AvvisoCertificato = {
@@ -282,14 +293,15 @@ export function avvisiCertificati(
} }
scaduti.sort(confrontaAvvisi); scaduti.sort(confrontaAvvisi);
inScadenza.sort(confrontaAvvisi); inScadenza.sort(confrontaAvvisi);
return { scaduti, inScadenza }; mancanti.sort((a, b) => a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome));
return { scaduti, inScadenza, mancanti };
} }
/** /**
* Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio * Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio
* certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa; `staff` sono gli * certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa, e `personaleMancante`
* altri della rosa (soglia 7 giorni), solo per l'admin, senza il suo nome perché già nel * dice che non l'ha ancora caricato (DD-046); `staff` sono gli altri della rosa (soglia 7
* personale. * giorni), solo per l'admin, senza il suo nome perché già nel personale.
*/ */
export function avvisiCertificatiUtente( export function avvisiCertificatiUtente(
rosa: GiocatoreSquadra[], rosa: GiocatoreSquadra[],
@@ -298,7 +310,8 @@ export function avvisiCertificatiUtente(
utente: { admin: boolean; base: GiocatoreSquadra | null }, utente: { admin: boolean; base: GiocatoreSquadra | null },
): { ): {
personale: AvvisoCertificato | undefined; personale: AvvisoCertificato | undefined;
staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null; personaleMancante: boolean;
staff: ReturnType<typeof avvisiCertificati> | null;
} { } {
const mio = utente.base const mio = utente.base
? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE) ? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
@@ -311,5 +324,5 @@ export function avvisiCertificatiUtente(
oggi, oggi,
) )
: null; : null;
return { personale, staff }; return { personale, personaleMancante: !!mio && mio.mancanti.length > 0, staff };
} }
+1 -1
View File
@@ -43,7 +43,7 @@ function NotFoundComponent() {
); );
} }
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) { function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
console.error(error); console.error(error);
const router = useRouter(); const router = useRouter();
@@ -0,0 +1,156 @@
-- 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
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | Cartella | Cosa verifica | Serve rete? |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, 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), 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), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Server di sviluppo con Node vecchio ## Server di sviluppo con Node vecchio
@@ -0,0 +1,399 @@
/**
* 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");
}
}
+101 -82
View File
@@ -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 ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle * su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "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 { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze"; import { 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,33 +282,51 @@ 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 inserita = await rest("eventi_app", { const pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
method: "POST", // OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
body: JSON.stringify({ for (const [id, giorno] of [
id: pizzataId, [pizzataId, "10"],
tipo: "evento", [pizzataFuturaId, "20"],
titolo: "Test obiettivi o6", ] as const) {
data: `${MESE_TEST}-20`, const inserita = await rest("eventi_app", {
}), method: "POST",
}); body: JSON.stringify({
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`); id,
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 eventiReali = await leggiEventi(); const tutti = await leggiEventi();
const o6InMese = obiettiviSquadra( const o6Di = (ids: string[], oggi: Date) =>
giocatori, obiettiviSquadra(
{ eventi: eventiReali, presenze: {}, pagelle: [] }, giocatori,
OGGI, { eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
).find((o) => o.id === "o6")!; oggi,
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database"); ).find((o) => o.id === "o6")!;
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(
o6MeseDopo.valore, o6Di([pizzataId], OGGI).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)",
); );
@@ -386,68 +404,69 @@ if (!locale) {
}, },
); );
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => { await prova(
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da "o7 percentuale di presenze collettive da eventi/risposte reali del database",
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita async () => {
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena // o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale. // convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
const allenamentoId = `${PREFISSO}-o7-allenamento`; const allenamentoId = `${PREFISSO}-o7-allenamento`;
const partitaId = `${PREFISSO}-o7-partita`; const partitaId = `${PREFISSO}-o7-partita`;
const OGGI_STR = "2099-01-20"; const [g1, g2, g3] = giocatori;
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({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }), body: JSON.stringify(righe),
}); });
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
}
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze, const eventiReali = (await leggiEventi()).filter(
// il ritardo conta). g3: assente a entrambi (0 presenze). (e) => e.id === allenamentoId || e.id === partitaId,
const [g1, g2, g3] = giocatori; );
const righe = [ const presenzeReali = {
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" }, ...(await leggiPresenze(allenamentoId)),
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" }, ...(await leggiPresenze(partitaId)),
{ 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 eventiReali = (await leggiEventi()).filter( const o7 = obiettiviSquadra([g1!, g2!, g3!], {
(e) => e.id === allenamentoId || e.id === partitaId, eventi: eventiReali,
); presenze: presenzeReali,
const presenzeReali = { pagelle: [],
...(await leggiPresenze(allenamentoId)), }).find((o) => o.id === "o7")!;
...(await leggiPresenze(partitaId)),
};
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({ assert.equal(
...g, o7.valore,
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR), 67,
})); "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`;
+9
View File
@@ -59,6 +59,8 @@ 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) {
@@ -67,6 +69,13 @@ 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(
+158 -17
View File
@@ -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-05T10:00:00Z"); const OGGI_SETTEMBRE = new Date("2026-09-20T10: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,6 +233,85 @@ 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: {},
@@ -245,23 +324,85 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
); );
} }
// --- somme sulla rosa -------------------------------------------------------- // --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) ---
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0); {
assert.equal(trova(vuoti, "o7").valore, sommaPresenze); const [g1, g2, g3, g4] = giocatori;
assert.equal( const rosa4 = [g1!, g2!, g3!, g4!];
trova(obiettiviSquadra([], contestoVuoto), "o7").valore, const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7");
0,
"rosa vuota: nessuna presenza",
);
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula assert.equal(o7(contestoVuoto).titolo, "Presenze collettive");
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30). assert.equal(o7(contestoVuoto).target, 90);
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! })); assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN");
assert.equal( assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0");
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
30, // Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%.
"somma di presenze note, indipendente dal roster reale", const base: ContestoObiettivi = {
); 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 },
+43 -8
View File
@@ -262,15 +262,31 @@ assert.deepEqual(
["g1"], ["g1"],
); );
assert.equal( const senzaCertificato = avvisiCertificati(
avvisiCertificati( [{ ...squadra[0]!, id: "g6" }],
[{ ...squadra[0]!, id: "g6" }], { g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } }, oggiTest,
oggiTest,
).inScadenza.length,
0,
"certificato mancante: nessun avviso",
); );
assert.equal(senzaCertificato.inScadenza.length + senzaCertificato.scaduti.length, 0);
assert.deepEqual(
senzaCertificato.mancanti.map((m) => m.giocatoreId),
["g6"],
"certificato mancante (DD-046): in `mancanti`, non tra scadenze",
);
// DD-046: profilo assente e scadenza senza file valgono come mancante; fuori rosa e allenatori no
const mancantiRosa = avvisiCertificati(
[
...rosaCertificati,
{ ...squadra[0]!, id: "g7", nome: "Zeno", cognome: "Zeta", attivo: true, tipo: "giocatore" },
{ ...squadra[0]!, id: "g8", nome: "Aldo", cognome: "Alfa", attivo: true, tipo: "giocatore" },
],
{
...profiliCertificati,
g8: { ...vuoto, giocatoreId: "g8", certificatoScadenza: "2026-12-01", certificatoPath: null },
},
oggiTest,
).mancanti.map((m) => m.giocatoreId);
assert.deepEqual(mancantiRosa, ["g8", "g7"], "g7 senza profilo, g8 senza file; ordine per cognome");
const dueInScadenza: Record<string, Profilo> = { const dueInScadenza: Record<string, Profilo> = {
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
@@ -412,5 +428,24 @@ const soloGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTes
}); });
assert.equal(soloGiocatore.personale?.giocatoreId, "g1"); assert.equal(soloGiocatore.personale?.giocatoreId, "g1");
assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff"); assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff");
assert.equal(soloGiocatore.personaleMancante, false);
// DD-046: chi non ha caricato il certificato lo vede nel personale; l'admin lo vede anche per gli altri
const adminSenzaFile = avvisiCertificatiUtente(rosaUtente, {}, oggiTest, {
admin: true,
base: rosaUtente[0]!,
});
assert.equal(adminSenzaFile.personaleMancante, true);
assert.equal(adminSenzaFile.personale, undefined);
assert.deepEqual(
adminSenzaFile.staff?.mancanti.map((m) => m.giocatoreId),
["g2"],
"lo staff non ripete il suo nome tra i mancanti",
);
assert.equal(
avvisiCertificatiUtente(rosaUtente, {}, oggiTest, { admin: true, base: null }).personaleMancante,
false,
"admin non giocatore: nessun avviso personale",
);
console.log("profili-core: ok"); console.log("profili-core: ok");
+6
View File
@@ -57,6 +57,12 @@ 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: {