From 985fc2fcc1b3745d168065b3ec558c4cb787741e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 10:18:22 +0200 Subject: [PATCH 1/8] Notifica di compleanno: auguri al festeggiato, avviso agli altri (DD-047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ogni giorno, dalle 8:00 (Europe/Rome), un job pg_cron orario manda a chi compie gli anni «Buon compleanno, ! 🎂» e a tutti gli altri membri attivi, allenatori compresi, «Oggi è il compleanno di » (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 --- PROJECT_STATE.md | 7 +- docs/CHANGELOG.md | 7 + docs/DATABASE.md | 24 +- docs/ROADMAP.md | 3 + docs/decisions/DD-047.md | 65 +++ docs/decisions/README.md | 1 + docs/modules/allenatore.md | 1 + docs/modules/notifiche-email.md | 2 +- docs/modules/notifiche.md | 53 ++- src/lib/notifiche-utente.ts | 4 +- ...20261006120000_m31_notifica_compleanno.sql | 156 +++++++ test/README.md | 12 +- test/integration/notifiche-compleanno.test.ts | 399 ++++++++++++++++++ test/unit/notifiche-utente.test.ts | 9 + test/unit/push-sw.test.ts | 6 + 15 files changed, 706 insertions(+), 43 deletions(-) create mode 100644 docs/decisions/DD-047.md create mode 100644 supabase/migrations/20261006120000_m31_notifica_compleanno.sql create mode 100644 test/integration/notifiche-compleanno.test.ts diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 052aefd..bb56ea7 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 01/10/2026 +Ultimo aggiornamento: 06/10/2026 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 @@ -14,8 +14,9 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato. - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio Project Ref `hetycilxgkdmccelwerq` non si usano più. -- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte - applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie +- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a + `m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il + 01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie `foto_path`: il codice che non la legge più (1.2.2) va rilasciato. - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2a30359..a1e0b57 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,13 @@ versioni `0.y.z` erano pre-release. ## [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. + ## [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. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 3779090..444af8a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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) | | `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) | +| `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) | | `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) | @@ -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. - Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta la rosa se `convocati` è vuoto; mai gli allenatori (DD-039). -- Il `CHECK` sul tipo ammette anche `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` restano i tipi dei pulsanti manuali. - 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`) @@ -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 [modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040. -| Funzione (migration) | Cosa fa | Job `pg_cron` | -| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | -| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | -| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | -| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | -| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — | -| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | -| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — | +| Funzione (migration) | Cosa fa | Job `pg_cron` | +| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) | +| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) | +| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) | +| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) | +| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) | +| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — | +| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — | +| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — | ## Badge diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d609f27..462f19e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,6 +47,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [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) +- [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 - [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina diff --git a/docs/decisions/DD-047.md b/docs/decisions/DD-047.md new file mode 100644 index 0000000..8678cc3 --- /dev/null +++ b/docs/decisions/DD-047.md @@ -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, ! 🎂`, **senza corpo**; + - **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di `, **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 e ` (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). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 67051e9..71c98c0 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -86,6 +86,7 @@ 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-039](DD-039.md) | Niente promemoria all'allenatore | | [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-044](DD-044.md) | Via la foto tessera dal profilo | diff --git a/docs/modules/allenatore.md b/docs/modules/allenatore.md index 10fb41b..c885cd9 100644 --- a/docs/modules/allenatore.md +++ b/docs/modules/allenatore.md @@ -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 . | | Turno palloni | no | I turni riguardano solo i giocatori. | | Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. | +| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. | | 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 diff --git a/docs/modules/notifiche-email.md b/docs/modules/notifiche-email.md index a2b2662..6c1937e 100644 --- a/docs/modules/notifiche-email.md +++ b/docs/modules/notifiche-email.md @@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti ## Quali notifiche e a chi -**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **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 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 diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9f3bbed..3da94a6 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi indipendenti: - **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, 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 quelle del turno palloni**, che restano solo push e in-app: DD-040). -| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email | -| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- | -| 1 | ~~Promemoria a 24 ore~~ — **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`). | — | — | — | — | -| 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) | -| 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ì | -| 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 | +| # | 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`). | — | — | — | — | +| 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ì | +| 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ì | +| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | +| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | +| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì | -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 -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 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 disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`. -| # | Notifica | Titolo | Corpo | -| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: `, oppure `…di oggi: ` | `Data: domani, `
`Ora: `
`Luogo: ` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | -| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | -| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: `
`Data: , ore `
`Incarico: custodia dei palloni al termine dell'evento`
`Riconsegna: ` — senza evento successivo la riga «Riconsegna» manca | -| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: `
`Data: , ore `
`Incarico: riconsegna dei palloni in custodia dal turno precedente` | -| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: `
`Data: , ore `
`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: ` | `Data: `
`Ora: `
`Luogo: `
`Richiesta di: ` (solo nel sollecito manuale, e solo se il nome c'è)
`Risposta attuale: nessuna` (oppure `forse`)
`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | -| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | ` · ore . Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | +| # | Notifica | Titolo | Corpo | +| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: `, oppure `…di oggi: ` | `Data: domani, `
`Ora: `
`Luogo: ` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) | +| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. | +| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: `
`Data: , ore `
`Incarico: custodia dei palloni al termine dell'evento`
`Riconsegna: ` — senza evento successivo la riga «Riconsegna» manca | +| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: `
`Data: , ore `
`Incarico: riconsegna dei palloni in custodia dal turno precedente` | +| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: `
`Data: , ore `
`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: ` | `Data: `
`Ora: `
`Luogo: `
`Richiesta di: ` (solo nel sollecito manuale, e solo se il nome c'è)
`Risposta attuale: nessuna` (oppure `forse`)
`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore | +| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | ` · ore . Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | +| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, ! 🎂` | Nessuno: solo il titolo | +| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di ` — con più festeggiati `
e `, o `, e `, 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` (`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. - La registrazione o il collegamento di un nuovo giocatore. - 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 @@ -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 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. +- **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») 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` | | Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` | | Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` | +| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` | | Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` | | L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) | diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts index 8310b0f..a02835d 100644 --- a/src/lib/notifiche-utente.ts +++ b/src/lib/notifiche-utente.ts @@ -24,7 +24,9 @@ export type TipoNotifica = | "sollecita_presenze_24h" | "sollecita_presenze_12h" | "sollecita_presenze_6h" - | "sondaggio_cacche"; + | "sondaggio_cacche" + | "compleanno" + | "compleanno_auguri"; export type NotificaUtente = { id: string; diff --git a/supabase/migrations/20261006120000_m31_notifica_compleanno.sql b/supabase/migrations/20261006120000_m31_notifica_compleanno.sql new file mode 100644 index 0000000..8fdba72 --- /dev/null +++ b/supabase/migrations/20261006120000_m31_notifica_compleanno.sql @@ -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(); diff --git a/test/README.md b/test/README.md index 40e20bf..5b125cc 100644 --- a/test/README.md +++ b/test/README.md @@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push ## Struttura -| Cartella | Cosa verifica | Serve rete? | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, 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ì | -| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | -| `helpers/` | Avvio del server di test e mini-harness condiviso | — | +| Cartella | Cosa verifica | Serve rete? | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì | +| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | ## Server di sviluppo con Node vecchio diff --git a/test/integration/notifiche-compleanno.test.ts b/test/integration/notifiche-compleanno.test.ts new file mode 100644 index 0000000..c00702e --- /dev/null +++ b/test/integration/notifiche-compleanno.test.ts @@ -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(percorso: string): Promise { + 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("giocatori_squadra?select=id,nome,nascita,attivo&order=id"); + const notifiche = () => + leggi( + `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>(); + + // 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>( + `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"); + } +} diff --git a/test/unit/notifiche-utente.test.ts b/test/unit/notifiche-utente.test.ts index 966aa99..4c98f7c 100644 --- a/test/unit/notifiche-utente.test.ts +++ b/test/unit/notifiche-utente.test.ts @@ -59,6 +59,8 @@ const tipiNuovi: TipoNotifica[] = [ "sollecita_presenze_24h", "sollecita_presenze_12h", "sollecita_presenze_6h", + "compleanno", + "compleanno_auguri", ]; const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP"; 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`); } +// 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 // `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico. const uiBits = readFileSync( diff --git a/test/unit/push-sw.test.ts b/test/unit/push-sw.test.ts index d188369..9bbec90 100644 --- a/test/unit/push-sw.test.ts +++ b/test/unit/push-sw.test.ts @@ -57,6 +57,12 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [ titolo: "🏐 Palloni", 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: { From ff02b7439c7b8383e6d9a1b42115679631b65781 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 10:38:19 +0200 Subject: [PATCH 2/8] Obiettivo evento di squadra al mese: conta quando arriva l'ora dell'evento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CHANGELOG.md | 5 +++ docs/modules/obiettivi-squadra.md | 8 ++++- src/lib/obiettivi.ts | 26 ++++++++++++++- test/unit/obiettivi.test.ts | 55 ++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a1e0b57..dccbd8b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -20,6 +20,11 @@ versioni `0.y.z` erano pre-release. 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. + ## [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. diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 248f3bd..2c8ee75 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -48,7 +48,7 @@ corrente dinamico (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 | | `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 primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica @@ -67,6 +67,12 @@ 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) 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. + `o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa. diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index 2ba8d47..a1260be 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -31,6 +31,24 @@ function meseCorrente(oggi: Date): string { 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. */ function aMese(oggi: Date): string { const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format( @@ -180,7 +198,13 @@ export function obiettiviSquadra( id: "o6", titolo: "1 evento di squadra al mese", descrizione: "Pizzate, cene e uscite fuori dal campo", - valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length, + // 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, unita: "eventi", emoji: "🍕", diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 24fad30..85d3634 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -216,7 +216,7 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 // --- 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 = { eventi: [evento("p1", "2026-09-12", "evento")], presenze: {}, @@ -233,6 +233,59 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 "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", + ); + // 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 = { eventi: [evento("al1", "2026-08-12", "allenamento")], presenze: {}, From 6b8cc33512b75cacceebf1617dcd2c5fb9810c2e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 11:06:51 +0200 Subject: [PATCH 3/8] Obiettivo Presenze collettive: percentuale al 90% su allenamenti e partite definiti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CHANGELOG.md | 2 + docs/modules/obiettivi-squadra.md | 36 +++--- src/lib/obiettivi.ts | 32 ++++- test/integration/obiettivi.test.ts | 183 ++++++++++++++++------------- test/unit/obiettivi.test.ts | 120 ++++++++++++++++--- 5 files changed, 254 insertions(+), 119 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dccbd8b..9115080 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -24,6 +24,8 @@ versioni `0.y.z` erano pre-release. - **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 diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 2c8ee75..4306ac3 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -37,22 +37,22 @@ corrente dinamico (vedi sotto). ## Obiettivi definiti -| id | Obiettivo | Calcolo | Target | Fonte | -| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- | -| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | -| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | -| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` | -| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | -| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | -| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | -| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | -| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | -| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | -| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` | +| id | Obiettivo | Calcolo | Target | Fonte | +| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- | +| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | +| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | +| `o7` | 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` | +| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | +| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | +| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | +| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | +| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | +| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente 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 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 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. @@ -73,6 +73,12 @@ un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/ 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 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. @@ -163,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, filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico (evento dentro/fuori mese), scadenza dinamica. -- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la - stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma. +- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali, + li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta). - **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola `serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato. - **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index a1260be..286b5d7 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -80,6 +80,27 @@ function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: 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) { const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno"); if (daRispondere.length === 0 || rosaSize === 0) return 0; @@ -97,7 +118,6 @@ export function obiettiviSquadra( ctx: ContestoObiettivi = contestoVuoto, oggi: Date = new Date(), ): ObiettivoSquadra[] { - const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0); const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length; const vittorie = ctx.vittorie ?? 0; const mese = meseCorrente(oggi); @@ -126,11 +146,11 @@ export function obiettiviSquadra( }, { id: "o7", - titolo: "250 presenze complessive", - descrizione: "Somma delle presenze di tutta la rosa in stagione", - valore: somma((g) => g.presenze), - target: 250, - unita: "presenze", + titolo: "Presenze collettive", + descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione", + valore: percentualePresenzeCollettive(ctx, rosa), + target: 90, + unita: "%", emoji: "🤝", impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.", }, diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts index 232780c..3b14cdb 100644 --- a/test/integration/obiettivi.test.ts +++ b/test/integration/obiettivi.test.ts @@ -2,7 +2,7 @@ * 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 * 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`). * * I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; import { giocatori } from "@/lib/crapp-data"; 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 { prova, riepilogo, salta } from "../helpers/prova"; @@ -282,33 +282,51 @@ if (!locale) { "o6 legge dal database l'evento sociale del mese, azzerandosi come o1", async () => { const pizzataId = `${PREFISSO}-pizzata`; - const inserita = await rest("eventi_app", { - method: "POST", - body: JSON.stringify({ - id: pizzataId, - tipo: "evento", - titolo: "Test obiettivi o6", - data: `${MESE_TEST}-20`, - }), - }); - if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`); + const pizzataFuturaId = `${PREFISSO}-pizzata-futura`; + // OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no. + for (const [id, giorno] of [ + [pizzataId, "10"], + [pizzataFuturaId, "20"], + ] as const) { + const inserita = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + 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 o6InMese = obiettiviSquadra( - giocatori, - { eventi: eventiReali, presenze: {}, pagelle: [] }, - OGGI, - ).find((o) => o.id === "o6")!; - assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database"); + const tutti = await leggiEventi(); + const o6Di = (ids: string[], oggi: Date) => + obiettiviSquadra( + giocatori, + { eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] }, + oggi, + ).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( - 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, "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 () => { - // o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da - // `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita - // la stessa funzione pura sui dati appena scritti, per verificare l'intera catena - // DB -> contaPresenzeGiocatore -> o7, non solo la somma finale. - const allenamentoId = `${PREFISSO}-o7-allenamento`; - const partitaId = `${PREFISSO}-o7-partita`; - const OGGI_STR = "2099-01-20"; + await prova( + "o7 percentuale di presenze collettive da eventi/risposte reali del database", + async () => { + // o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i + // convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7. + const allenamentoId = `${PREFISSO}-o7-allenamento`; + const partitaId = `${PREFISSO}-o7-partita`; + const [g1, g2, g3] = giocatori; - for (const [id, tipo, data] of [ - [allenamentoId, "allenamento", "2099-01-05"], - [partitaId, "partita", "2099-01-08"], - ] as const) { - const inserito = await rest("eventi_app", { + for (const [id, tipo, data] of [ + [allenamentoId, "allenamento", "2099-01-05"], + [partitaId, "partita", "2099-01-08"], + ] as const) { + 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", - 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, - // il ritardo conta). g3: assente a entrambi (0 presenze). - const [g1, g2, g3] = giocatori; - 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", - body: JSON.stringify(righe), - }); - if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`); + const eventiReali = (await leggiEventi()).filter( + (e) => e.id === allenamentoId || e.id === partitaId, + ); + const presenzeReali = { + ...(await leggiPresenze(allenamentoId)), + ...(await leggiPresenze(partitaId)), + }; - const eventiReali = (await leggiEventi()).filter( - (e) => e.id === allenamentoId || e.id === partitaId, - ); - const presenzeReali = { - ...(await leggiPresenze(allenamentoId)), - ...(await leggiPresenze(partitaId)), - }; + const o7 = obiettiviSquadra([g1!, g2!, g3!], { + eventi: eventiReali, + presenze: presenzeReali, + pagelle: [], + }).find((o) => o.id === "o7")!; - const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({ - ...g, - presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR), - })); - - 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", - ); - }); + assert.equal( + o7.valore, + 67, + "4 presenze su 6 posti (il ritardo conta), calcolate dal database", + ); + }, + ); await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => { const matchId = `${PREFISSO}-o12-m1`; diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 85d3634..7f68d02 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -274,6 +274,32 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 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")], @@ -298,23 +324,85 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 ); } -// --- somme sulla rosa -------------------------------------------------------- -const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0); -assert.equal(trova(vuoti, "o7").valore, sommaPresenze); -assert.equal( - trova(obiettiviSquadra([], contestoVuoto), "o7").valore, - 0, - "rosa vuota: nessuna presenza", -); +// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) --- +{ + const [g1, g2, g3, g4] = giocatori; + const rosa4 = [g1!, g2!, g3!, g4!]; + const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7"); -// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula -// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30). -const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! })); -assert.equal( - trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore, - 30, - "somma di presenze note, indipendente dal roster reale", -); + assert.equal(o7(contestoVuoto).titolo, "Presenze collettive"); + assert.equal(o7(contestoVuoto).target, 90); + assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN"); + assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0"); + + // Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%. + 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, + t2: rispondi(b) as Record, + }, + 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[] = [ { match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 }, From c2c3b249f20b728f141dd56fef45163ffb5f6d72 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 11:34:58 +0200 Subject: [PATCH 4/8] Obiettivi cliccabili con card di dettaglio; presenze del mese sui convocati MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ogni obiettivo di squadra apre un drawer che spiega come si calcola, cosa conta, cosa è una presenza persa, cosa resta fuori, periodo, fonte dei dati ed esempio. «90% di presenze del mese» usa ora i convocati dell'evento come posti (tutta la rosa se non ce ne sono), come le presenze collettive: chi non è convocato non abbassa più la percentuale. Test unit, card e integrazione; docs aggiornate. Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 5 + docs/modules/obiettivi-squadra.md | 36 ++-- src/components/crapp/ObiettivoDrawer.tsx | 142 +++++++++++++++ src/lib/obiettivi.ts | 214 +++++++++++++++++++++-- src/routes/index.tsx | 41 +++-- src/routes/squadra.tsx | 70 ++++---- test/integration/obiettivi.test.ts | 60 +++++++ test/unit/notifiche-smart.test.ts | 1 + test/unit/obiettivi-dettaglio.test.ts | 106 +++++++++++ test/unit/obiettivi.test.ts | 177 +++++++++++++++++++ 10 files changed, 779 insertions(+), 73 deletions(-) create mode 100644 src/components/crapp/ObiettivoDrawer.tsx create mode 100644 test/unit/obiettivi-dettaglio.test.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9115080..958d218 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,9 @@ versioni `0.y.z` erano pre-release. ### Aggiunto +- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che + spiega tutto: come si calcola, cosa conta e cosa no, periodo di validità, fonte dei dati, un esempio. Nessuna + migration. - **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 @@ -22,6 +25,8 @@ versioni `0.y.z` erano pre-release. ### Modificato +- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se + non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Nessuna migration. - **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 diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 4306ac3..a84df8c 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -39,7 +39,7 @@ corrente dinamico (vedi sotto). | 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 | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` | | `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | | `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` | | `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | @@ -51,7 +51,12 @@ corrente dinamico (vedi sotto). | `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 -primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica +primo obiettivo non completato). +Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei +badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i +dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in +`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo +`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10 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" @@ -75,8 +80,9 @@ 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ò +conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i +convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione +`percentualePresenze()`. 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 @@ -156,14 +162,22 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti da un backend reale, non solo funzione pura con contesto costruito a mano). -| Obiettivi | Unit test | Integration test | -| ---------- | ----------------------------- | -------------------------------------------------------- | -| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | +| Obiettivi | Unit test | Integration test | +| ---------- | ----------------------------- | -------------------------------------------------------------------------------- | +| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) | +| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | +- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti + contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa, + confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia + del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test + diventa rosso. +- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi, + «Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato + in corso/completato. - **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/ `risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi, diff --git a/src/components/crapp/ObiettivoDrawer.tsx b/src/components/crapp/ObiettivoDrawer.tsx new file mode 100644 index 0000000..2662f1b --- /dev/null +++ b/src/components/crapp/ObiettivoDrawer.tsx @@ -0,0 +1,142 @@ +import { useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { formatData } from "@/lib/crapp-data"; +import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi"; +import { Barra } from "@/components/motion/Barra"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; + +function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) { + return ( +
+

+ {titolo} +

+
    + {voci.map((v) => ( +
  • + + {segno} + + {v} +
  • + ))} +
+
+ ); +} + +function Blocco({ titolo, testo }: { titolo: string; testo: string }) { + return ( +
+

+ {titolo} +

+

{testo}

+
+ ); +} + +/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */ +export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) { + const pct = progressoObiettivo(o); + const fatto = pct >= 100; + const d = o.dettaglio; + return ( +
+
+ + {o.emoji} + +
+ {o.titolo} + + {o.descrizione} + +
+
+ +
+
+

+ Stato attuale +

+ + {fatto ? "Completato" : "In corso"} + +
+

+ {o.valore} su {o.target}{" "} + {o.unita} +

+ +

+ {pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} +

+

{microcopyObiettivo(o)}

+
+ + + + {d.perse ? : null} + + + + + +

+ {o.impatto} +

+
+ ); +} + +/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */ +export function ObiettivoDrawer({ + obiettivo, + children, +}: { + obiettivo: ObiettivoSquadra; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + + {children} + + + {obiettivo.titolo} + {obiettivo.descrizione} + + + + + + + + + + ); +} diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index 286b5d7..7c9064f 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -14,6 +14,25 @@ export type ObiettivoSquadra = { emoji: string; /** Frase breve che spiega come ogni giocatore può spostare l'ago. */ impatto: string; + /** Spiegazione completa mostrata nella card di dettaglio: nessun punto oscuro. */ + dettaglio: DettaglioObiettivo; +}; + +export type DettaglioObiettivo = { + /** Cosa misura l'obiettivo e come si arriva al numero mostrato, in parole semplici. */ + comeSiCalcola: string; + /** Cosa fa salire il numero. */ + conta: string[]; + /** Cosa è una presenza persa: occupa un posto ma lo lascia vuoto, quindi abbassa la percentuale. */ + perse?: string[]; + /** Cosa resta fuori dal calcolo: non sale e non scende. */ + nonConta: string[]; + /** Quando l'obiettivo parte, quando si azzera o scade. */ + periodo: string; + /** Da dove arrivano i dati e quando si aggiornano. */ + fonte: string; + /** Esempio concreto con numeri. */ + esempio: string; }; export type ContestoObiettivi = { @@ -67,28 +86,21 @@ function fineMese(oggi: Date): string { return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`; } -function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) { - const delMese = ctx.eventi.filter( - (e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"), - ); - if (delMese.length === 0 || rosaSize === 0) return 0; - const posti = delMese.length * rosaSize; - const presenti = delMese.reduce((s, e) => { - const risposte = ctx.presenze[e.id] ?? {}; - return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length; - }, 0); - 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. + * Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da + * `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non + * è convocato non occupa un posto, quindi la sua assenza non pesa. */ -function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) { +function percentualePresenze( + ctx: ContestoObiettivi, + rosa: Giocatore[], + filtro: (e: Evento) => boolean = () => true, +) { let posti = 0; let presenti = 0; for (const e of ctx.eventi) { if (e.tipo !== "partita" && e.tipo !== "allenamento") continue; + if (!filtro(e)) 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] ?? {}; @@ -126,12 +138,35 @@ export function obiettiviSquadra( id: "o1", titolo: `90% di presenze ${aMese(oggi)}`, descrizione: "Media presenze su partite e allenamenti del mese", - valore: percentualePresenzeMese(ctx, rosa.length, mese), + valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)), target: 90, unita: "%", scadenza: fineMese(oggi), emoji: "📣", impatto: "Ogni sì in più alza la media di tutta la squadra.", + dettaglio: { + comeSiCalcola: + "Si guardano solo le partite e gli allenamenti del mese in corso. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi del mese.", + conta: [ + "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento del mese.", + "Vale anche per gli eventi futuri del mese: appena confermi, la percentuale sale.", + ], + perse: [ + "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.", + "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.", + ], + nonConta: [ + "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.", + "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.", + "Gli eventi degli altri mesi.", + ], + periodo: + "Solo il mese in corso: si azzera il primo giorno di ogni mese e si chiude l'ultimo giorno del mese. Il mese dopo riparte da zero, con il suo titolo e la sua scadenza.", + fonte: + "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.", + esempio: + "Ad agosto ci sono 2 allenamenti. Il primo ha 12 convocati e 10 rispondono «presente»: 10 posti pieni su 12. Il secondo ha 10 convocati e 9 «presenti»: 9 su 10. In tutto 19 posti pieni su 22, cioè 86%. I giocatori non convocati non sono nel conto: per arrivare al 90% servono almeno 20 posti pieni su 22.", + }, }, { id: "o2", @@ -143,16 +178,55 @@ export function obiettiviSquadra( scadenza: fineMese(oggi), emoji: "⚡", impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.", + dettaglio: { + comeSiCalcola: + "Per ogni evento in calendario ci sono tanti posti quanti sono i giocatori in rosa. Si contano le risposte date, di qualsiasi tipo, e si divide per tutti i posti.", + conta: [ + "Qualsiasi risposta: presente, in ritardo o assente. Conta aver risposto, non cosa si è risposto.", + "Partite, allenamenti ed eventi di squadra.", + ], + nonConta: [ + "I compleanni: non richiedono risposta.", + "Un evento a cui non hai ancora risposto.", + ], + periodo: + "Considera tutti gli eventi in programma, non solo quelli del mese. La scadenza indicata è la fine del mese corrente.", + fonte: "Eventi del calendario e risposte dei giocatori. Si aggiorna a ogni risposta.", + esempio: + "Con 14 giocatori e 10 eventi i posti sono 140. Con 126 risposte date, siamo al 90%.", + }, }, { id: "o7", titolo: "Presenze collettive", descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione", - valore: percentualePresenzeCollettive(ctx, rosa), + valore: percentualePresenze(ctx, rosa), target: 90, unita: "%", emoji: "🤝", impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.", + dettaglio: { + comeSiCalcola: + "Si guardano tutte le partite e tutti gli allenamenti della stagione, passati e futuri. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi insieme.", + conta: [ + "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento.", + "Anche le conferme per gli eventi futuri: appena confermi, la percentuale sale.", + ], + perse: [ + "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.", + "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.", + ], + nonConta: [ + "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.", + "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.", + ], + periodo: + "Tutta la stagione: non si azzera mai e non ha scadenza. Conta tutto ciò che c'è in calendario, quindi un evento cancellato smette di contare e uno nuovo aggiunge i suoi posti.", + fonte: + "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.", + esempio: + "In stagione ci sono 3 eventi: un allenamento con 12 convocati e 10 presenti, uno con 10 convocati e 9 presenti, una partita con 14 convocati e 13 presenti. Posti pieni 32 su 36, cioè 89%: per il 90% manca una presenza in più. La differenza con quello del mese è solo il periodo: qui contano tutti gli eventi insieme, senza azzerarsi.", + }, }, { id: "o12", @@ -163,6 +237,19 @@ export function obiettiviSquadra( unita: "di media", emoji: "📝", impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.", + dettaglio: { + comeSiCalcola: + "Media di tutti i voti dati dai giocatori nelle pagelle dopo le partite, arrotondata a una cifra decimale.", + conta: ["Ogni voto dato in una pagella, a qualsiasi compagno."], + nonConta: [ + "Gli autovoti: non si può votare se stessi.", + "Le pagelle non compilate: non pesano sulla media.", + ], + periodo: "Tutta la stagione. Non si azzera e non ha scadenza.", + fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.", + esempio: + "Se i voti dati finora sono 7, 8 e 7, la media è 7.3: mancano 0.2 per arrivare a 7.5.", + }, }, { id: "o13", @@ -173,6 +260,16 @@ export function obiettiviSquadra( unita: "voti", emoji: "🗳️", impatto: "Vota i compagni a fine partita: bastano due minuti.", + dettaglio: { + comeSiCalcola: + "Si contano tutti i voti dati nelle pagelle durante la stagione. Un voto è un giocatore che valuta un compagno dopo una partita.", + conta: ["Ogni singolo voto dato a un compagno, non ogni pagella intera."], + nonConta: ["Le pagelle non compilate.", "Gli autovoti, che non sono ammessi."], + periodo: "Tutta la stagione. Non si azzera e non ha scadenza.", + fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.", + esempio: + "Se dopo una partita 10 giocatori votano 9 compagni ciascuno, la squadra aggiunge 90 voti.", + }, }, { id: "o11", @@ -183,6 +280,21 @@ export function obiettiviSquadra( unita: "giocatori", emoji: "🔗", impatto: "Tieni viva la tua serie e sblocchi anche questo.", + dettaglio: { + comeSiCalcola: + "Si conta quanti giocatori hanno una serie di almeno 3 allenamenti consecutivi senza saltarne nemmeno uno.", + conta: ["Un giocatore con 3 o più allenamenti di fila in cui era presente o in ritardo."], + nonConta: [ + "Chi ha saltato un allenamento: la serie riparte da zero.", + "Un allenamento passato senza risposta vale come assenza e azzera la serie.", + "Le partite: la serie riguarda solo gli allenamenti.", + "Un allenamento saltato per infortunio non la azzera: la serie resta ferma al valore di prima.", + ], + periodo: "Si aggiorna di continuo sugli allenamenti passati. Non ha scadenza.", + fonte: "Presenze agli allenamenti. Vedi anche la serie personale nel tuo profilo.", + esempio: + "Il target è 12 perché è il numero minimo per un allenamento 6 contro 6. Con 9 giocatori in serie ne mancano 3.", + }, }, { id: "o3", @@ -193,6 +305,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🎉", impatto: "Una prestazione di gruppo e il primo passo è fatto.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con la prima vittoria.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o4", @@ -203,6 +331,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🔥", impatto: "Ogni vittoria ci avvicina ai playoff.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 5 vittorie.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o5", @@ -213,6 +357,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🏆", impatto: "L'obiettivo grande: serve tutta la stagione insieme.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 10 vittorie.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o6", @@ -229,6 +389,24 @@ export function obiettiviSquadra( unita: "eventi", emoji: "🍕", impatto: "Il gruppo si costruisce anche fuori dal campo.", + dettaglio: { + comeSiCalcola: + "Si conta quanti eventi di squadra del mese corrente sono già iniziati. Basta uno per completare l'obiettivo.", + conta: [ + "Pizzate, cene e uscite fuori dal campo inserite nel calendario come «evento».", + "Solo quando l'ora dell'evento è arrivata. Se manca l'ora vale la mezzanotte.", + ], + nonConta: [ + "Un evento ancora nel futuro: creato oggi per la settimana prossima non conta ancora.", + "Partite e allenamenti.", + "Eventi di altri mesi.", + ], + periodo: + "Si azzera il primo giorno di ogni mese. Il passaggio a completato compare al primo aggiornamento della pagina dopo l'ora dell'evento.", + fonte: "Eventi del calendario.", + esempio: + "Una pizzata fissata il 20 alle 20:30: fino a quell'ora l'obiettivo resta 0/1, dopo diventa 1/1.", + }, }, ]; } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2c72149..6506de1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -9,6 +9,7 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; import { Barra } from "@/components/motion/Barra"; import { Numero } from "@/components/motion/Numero"; +import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useEventi, type Evento } from "@/lib/eventi"; import { useRispostePresenze } from "@/lib/presenze"; @@ -237,20 +238,32 @@ function Index() { } > {obiettivo ? ( - -
- {obiettivo.emoji} {obiettivo.titolo} -
- -

- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. -

-

- {microcopyObiettivo(obiettivo)} -

-

{obiettivo.impatto}

-
+ + + ) : null} )} diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 13591a6..f4deba7 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { Cake, ChevronDown, Crown, Trophy } from "lucide-react"; +import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, StatTile } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { Avatar } from "@/components/crapp/Avatar"; import { formatData, inizialiDa } from "@/lib/crapp-data"; +import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useRosa, @@ -432,38 +433,47 @@ function Squadra() { const pct = progressoObiettivo(o); const fatto = pct >= 100; return ( - = 90 ? "ring-accent/40" : "ring-transparent", - )} - > -
- {o.emoji} -
-

{o.titolo}

-

{o.descrizione}

-
- + +
- -

- {o.valore}/{o.target} {o.unita} · {pct}% - {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} -

-

{microcopyObiettivo(o)}

-

{o.impatto}

+
+ {o.emoji} +
+

{o.titolo}

+

{o.descrizione}

+
+ + {fatto ? "Completato" : `${pct}%`} + +
+ +

+ {o.valore}/{o.target} {o.unita} · {pct}% + {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} +

+

+ {microcopyObiettivo(o)} +

+

{o.impatto}

+

+ Come funziona +

+ +
); })} diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts index 3b14cdb..52d833c 100644 --- a/test/integration/obiettivi.test.ts +++ b/test/integration/obiettivi.test.ts @@ -468,6 +468,66 @@ if (!locale) { }, ); + await prova( + "o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no", + async () => { + // Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa: + // g1 presente, g2 assente (presenza persa), g3 in ritardo. g4 non è convocato ma risponde + // «assente»: non deve pesare. La cena del mese ha risposte «assente» e non deve contare. + const allenamentoId = `${PREFISSO}-o1-convocati-allenamento`; + const cenaId = `${PREFISSO}-o1-convocati-cena`; + const [g1, g2, g3, g4] = giocatori; + for (const [id, tipo, data, convocati] of [ + [allenamentoId, "allenamento", "2099-03-12", [g1!.id, g2!.id, g3!.id]], + [cenaId, "evento", "2099-03-14", [g1!.id, g2!.id, g3!.id]], + ] as const) { + const inserito = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id, + tipo, + titolo: `Test o1 convocati (${tipo})`, + data, + convocati, + }), + }); + if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); + } + const righe = [ + { evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" }, + { evento_id: allenamentoId, giocatore_id: g2!.id, stato: "assente" }, + { evento_id: allenamentoId, giocatore_id: g3!.id, stato: "ritardo" }, + { evento_id: allenamentoId, giocatore_id: g4!.id, stato: "assente" }, + { evento_id: cenaId, giocatore_id: g1!.id, stato: "assente" }, + { evento_id: cenaId, giocatore_id: g2!.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 ids = [allenamentoId, cenaId]; + const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id)); + const presenzeReali = { + ...(await leggiPresenze(allenamentoId)), + ...(await leggiPresenze(cenaId)), + }; + + const o1 = obiettiviSquadra( + giocatori, + { eventi: eventiReali, presenze: presenzeReali, pagelle: [] }, + OGGI, + ).find((o) => o.id === "o1")!; + + assert.equal( + o1.valore, + 67, + "2 presenti (presente e ritardo) su 3 convocati; g4 non convocato e la cena non contano", + ); + }, + ); + await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => { const matchId = `${PREFISSO}-o12-m1`; const [g1, g2, g3] = giocatori; diff --git a/test/unit/notifiche-smart.test.ts b/test/unit/notifiche-smart.test.ts index 72a3245..f84f8fb 100644 --- a/test/unit/notifiche-smart.test.ts +++ b/test/unit/notifiche-smart.test.ts @@ -69,6 +69,7 @@ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({ unita: "%", emoji: "🎯", impatto: "", + dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" }, }); assert.deepEqual( ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")), diff --git a/test/unit/obiettivi-dettaglio.test.ts b/test/unit/obiettivi-dettaglio.test.ts new file mode 100644 index 0000000..9e7f6d6 --- /dev/null +++ b/test/unit/obiettivi-dettaglio.test.ts @@ -0,0 +1,106 @@ +/** Check della card di dettaglio degli obiettivi: `bun test/unit/obiettivi-dettaglio.test.ts`. */ +import assert from "node:assert/strict"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SchedaObiettivo } from "@/components/crapp/ObiettivoDrawer"; +import { Drawer } from "@/components/ui/drawer"; +import { giocatori } from "@/lib/crapp-data"; +import { contestoVuoto, obiettiviSquadra, type ObiettivoSquadra } from "@/lib/obiettivi"; + +const OGGI = new Date("2026-08-15T10:00:00Z"); +const tutti = obiettiviSquadra(giocatori, contestoVuoto, OGGI); + +/** Rende la scheda dentro un Drawer aperto, come fa l'app (titolo e descrizione ne hanno bisogno). */ +const scheda = (o: ObiettivoSquadra) => + renderToStaticMarkup( + createElement(Drawer, { open: true }, createElement(SchedaObiettivo, { o })), + ); + +// --- ogni obiettivo ha tutti i testi, nessun campo vuoto ---------------------------- +const ids = tutti.map((o) => o.id).sort(); +assert.deepEqual( + ids, + ["o1", "o11", "o12", "o13", "o2", "o3", "o4", "o5", "o6", "o7"], + "gli obiettivi sono quelli attesi: se ne aggiungi uno, scrivi il suo dettaglio", +); + +for (const o of tutti) { + const d = o.dettaglio; + for (const [nome, testo] of Object.entries({ + comeSiCalcola: d.comeSiCalcola, + periodo: d.periodo, + fonte: d.fonte, + esempio: d.esempio, + })) { + assert.ok(testo.trim().length > 20, `${o.id}: «${nome}» troppo corto o vuoto`); + } + for (const voce of [...d.conta, ...d.nonConta, ...(d.perse ?? [])]) { + assert.ok(voce.trim().length > 10, `${o.id}: voce di elenco vuota o troppo corta`); + } + assert.equal(new Set(d.conta).size, d.conta.length, `${o.id}: voci «conta» duplicate`); + assert.equal(new Set(d.nonConta).size, d.nonConta.length, `${o.id}: voci «non conta» duplicate`); +} + +// --- «presenza persa» solo sulle due percentuali di presenze ------------------------ +for (const o of tutti) { + const haPerse = Boolean(o.dettaglio.perse && o.dettaglio.perse.length > 0); + assert.equal(haPerse, o.id === "o1" || o.id === "o7", `${o.id}: presenza persa solo su o1 e o7`); +} + +// --- le due presenze spiegano gli stessi punti chiave ------------------------------- +for (const id of ["o1", "o7"]) { + const d = tutti.find((o) => o.id === id)!.dettaglio; + const tutto = [d.comeSiCalcola, ...d.conta, ...(d.perse ?? []), ...d.nonConta].join(" "); + for (const parola of ["convocat", "presente", "in ritardo", "assente", "cene"]) { + assert.ok(tutto.toLowerCase().includes(parola), `${id}: la spiegazione non cita «${parola}»`); + } + assert.ok( + d.nonConta.some((v) => /non è convocato/i.test(v)), + `${id}: dice che il non convocato non pesa`, + ); +} +const mese = tutti.find((o) => o.id === "o1")!.dettaglio; +const totale = tutti.find((o) => o.id === "o7")!.dettaglio; +assert.ok(/mese/i.test(mese.periodo) && /azzera/i.test(mese.periodo), "o1: si azzera ogni mese"); +assert.ok(/stagione/i.test(totale.periodo) && /mai/i.test(totale.periodo), "o7: non si azzera mai"); + +// --- la card renderizzata mostra tutte le sezioni ------------------------------------ +const o1 = tutti.find((o) => o.id === "o1")!; +const html1 = scheda(o1); +for (const titolo of [ + "Stato attuale", + "Come si calcola", + "Cosa conta", + "Presenza persa", + "Non entra nel calcolo", + "Quando vale", + "Da dove arrivano i dati", + "Un esempio", +]) { + assert.ok(html1.includes(titolo), `la card di o1 non mostra «${titolo}»`); +} +assert.ok(html1.includes(o1.titolo.replace(/&/g, "&")), "titolo"); +assert.ok(html1.includes(o1.dettaglio.comeSiCalcola), "testo «come si calcola»"); +assert.ok(html1.includes(o1.impatto), "frase di impatto"); +assert.ok(html1.includes("entro il"), "o1 ha una scadenza mensile"); +assert.ok( + !html1.includes("Cosa non conta"), + "con «presenza persa» la sezione si chiama diversamente", +); + +// Un obiettivo senza «presenza persa» ha «Cosa non conta» e nessuna sezione in più. +const o12 = tutti.find((o) => o.id === "o12")!; +const html12 = scheda(o12); +assert.ok(html12.includes("Cosa non conta"), "o12: «Cosa non conta»"); +assert.ok(!html12.includes("Presenza persa"), "o12: nessuna «presenza persa»"); +assert.ok(!html12.includes("Non entra nel calcolo"), "o12: nessun «non entra nel calcolo»"); +assert.ok(!html12.includes("entro il"), "o12 non ha scadenza"); + +// --- stato: in corso / completato, e i numeri mostrati -------------------------------- +const finto = (valore: number): ObiettivoSquadra => ({ ...o12, valore, target: 10 }); +assert.ok(scheda(finto(4)).includes("In corso"), "sotto il target: in corso"); +assert.ok(scheda(finto(4)).includes("40%"), "percentuale mostrata"); +assert.ok(scheda(finto(10)).includes("Completato"), "al target: completato"); +assert.ok(!scheda(finto(10)).includes("In corso"), "al target non è più in corso"); + +console.log("obiettivi-dettaglio: ok"); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 7f68d02..77da3cc 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -100,6 +100,169 @@ const soloCompleanni: ContestoObiettivi = { }; assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0); +// --- o1: chi non è convocato non occupa un posto, la sua assenza non pesa ------- +{ + const [g1, g2] = giocatori; + const mese: ContestoObiettivi = { + ...contestoVuoto, + eventi: [{ ...evento("m1", "2026-08-10", "allenamento"), convocati: [g1!.id, g2!.id] }], + presenze: { m1: { [g1!.id]: "presente", [g2!.id]: "assente" } }, + }; + assert.equal( + trova(obiettiviSquadra(giocatori, mese, OGGI_AGOSTO), "o1").valore, + 50, + "su 2 convocati 1 presente e 1 assente: 50%, il resto della rosa non conta", + ); + assert.equal( + trova( + obiettiviSquadra( + giocatori, + { ...mese, eventi: [evento("m1", "2026-08-10", "allenamento")] }, + OGGI_AGOSTO, + ), + "o1", + ).valore, + Math.round((1 / giocatori.length) * 100), + "senza convocati i posti sono tutta la rosa", + ); +} + +// --- presenze (o1 mese, o7 totale): casi limite del calcolo ------------------------ +{ + const [g1, g2, g3, g4] = giocatori; + const P = (...x: [string, "presente" | "ritardo" | "assente" | "forse" | "infortunato"][]) => + Object.fromEntries(x); + const o1v = (c: ContestoObiettivi, oggi = OGGI_AGOSTO) => + trova(obiettiviSquadra(giocatori, c, oggi), "o1").valore; + const o7v = (c: ContestoObiettivi) => trova(obiettiviSquadra(giocatori, c), "o7").valore; + const conv = (id: string, data: string, tipo: Evento["tipo"], convocati: string[]) => ({ + ...evento(id, data, tipo), + convocati, + }); + + // Solo allenamenti e partite entrano nel conto: cene, eventi e compleanni restano fuori + // anche se hanno risposte di presenza. + const conAltri: ContestoObiettivi = { + ...contestoVuoto, + eventi: [ + conv("t1", "2026-08-05", "allenamento", [g1!.id, g2!.id]), + conv("t2", "2026-08-06", "evento", [g1!.id, g2!.id]), + conv("t3", "2026-08-07", "compleanno", [g1!.id, g2!.id]), + ], + presenze: { + t1: P([g1!.id, "presente"], [g2!.id, "assente"]), + t2: P([g1!.id, "assente"], [g2!.id, "assente"]), + t3: P([g1!.id, "presente"], [g2!.id, "presente"]), + }, + }; + assert.equal(o1v(conAltri), 50, "o1: solo l'allenamento conta (1 su 2)"); + assert.equal(o7v(conAltri), 50, "o7: solo l'allenamento conta (1 su 2)"); + + // Assente, forse, infortunato e nessuna risposta sono tutti presenze perse: posto vuoto. + const perse: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("p1", "2026-08-05", "partita", [g1!.id, g2!.id, g3!.id, g4!.id])], + presenze: { p1: P([g1!.id, "assente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) }, + }; + assert.equal(o1v(perse), 0, "o1: nessuna presenza, 0 su 4"); + assert.equal(o7v(perse), 0, "o7: nessuna presenza, 0 su 4"); + const unaPresente = { + ...perse, + presenze: { p1: P([g1!.id, "presente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) }, + }; + assert.equal(o1v(unaPresente), 25, "o1: 1 presente su 4 convocati"); + assert.equal(o7v(unaPresente), 25, "o7: 1 presente su 4 convocati"); + + // Un non convocato che risponde non gonfia la percentuale, e se è assente non la abbassa. + const nonConv: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("n1", "2026-08-05", "allenamento", [g1!.id])], + presenze: { n1: P([g1!.id, "presente"], [g2!.id, "assente"], [g3!.id, "presente"]) }, + }; + assert.equal(o1v(nonConv), 100, "o1: l'assente non convocato non pesa, il presente nemmeno"); + assert.equal(o7v(nonConv), 100, "o7: idem"); + + // Il mese e il totale coincidono quando tutti gli eventi sono del mese corrente; + // il totale invece comprende anche gli altri mesi. + const dueMesi: ContestoObiettivi = { + ...contestoVuoto, + eventi: [ + conv("m1", "2026-08-05", "allenamento", [g1!.id, g2!.id]), + conv("m2", "2026-07-05", "allenamento", [g1!.id, g2!.id]), + ], + presenze: { + m1: P([g1!.id, "presente"], [g2!.id, "presente"]), + m2: P([g1!.id, "assente"], [g2!.id, "assente"]), + }, + }; + assert.equal(o1v(dueMesi), 100, "o1: guarda solo agosto"); + assert.equal(o7v(dueMesi), 50, "o7: guarda agosto e luglio insieme"); + assert.equal(o1v(dueMesi, new Date("2026-07-15T10:00:00Z")), 0, "o1 a luglio: solo luglio"); + const soloAgosto = { ...dueMesi, eventi: [dueMesi.eventi[0]!] }; + assert.equal(o1v(soloAgosto), o7v(soloAgosto), "stessi eventi: stessa percentuale"); + + // Confine di mese nel fuso Europe/Rome: il 30 giugno alle 22:30 UTC a Roma è già luglio. + const sera = new Date("2026-06-30T22:30:00Z"); + const luglio: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("l1", "2026-07-01", "allenamento", [g1!.id])], + presenze: { l1: P([g1!.id, "presente"]) }, + }; + assert.equal(o1v(luglio, sera), 100, "a Roma è già il 1° luglio: l'evento di luglio conta"); + const giugno = { ...luglio, eventi: [conv("l1", "2026-06-30", "allenamento", [g1!.id])] }; + assert.equal(o1v(giugno, sera), 0, "a Roma giugno è finito: l'evento del 30 giugno non conta"); + + // Convocato sconosciuto (non in rosa): nessun posto, quindi 0 e non NaN. + const fantasma: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("f1", "2026-08-05", "allenamento", ["fantasma"])], + presenze: { f1: P(["fantasma", "presente"]) }, + }; + assert.equal(o1v(fantasma), 0, "o1: convocato fuori rosa, 0 posti"); + assert.equal(o7v(fantasma), 0, "o7: convocato fuori rosa, 0 posti"); + + // Evento senza nessuna risposta: i posti ci sono, le presenze no. + const senzaRisposte: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("r1", "2026-08-05", "allenamento", [g1!.id, g2!.id])], + }; + assert.equal(o1v(senzaRisposte), 0, "o1: nessuna risposta = 0%"); + + // Rosa vuota: nessuna divisione per zero. + assert.equal( + trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o1").valore, + 0, + "rosa vuota: o1", + ); + assert.equal( + trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o7").valore, + 0, + "rosa vuota: o7", + ); + + // Arrotondamento: 2 su 3 = 67%, e il progresso non supera mai il 100%. + const treConvocati: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("a3", "2026-08-05", "allenamento", [g1!.id, g2!.id, g3!.id])], + presenze: { a3: P([g1!.id, "presente"], [g2!.id, "ritardo"], [g3!.id, "assente"]) }, + }; + assert.equal(o1v(treConvocati), 67, "o1: 2 su 3 arrotondato"); + assert.equal(o7v(treConvocati), 67, "o7: 2 su 3 arrotondato"); + + // Soglia del 90%: la percentuale arriva al target e l'obiettivo risulta completato. + const dieci = giocatori.slice(0, 10).map((g) => g.id); + const novePresenti: ContestoObiettivi = { + ...contestoVuoto, + eventi: [conv("d1", "2026-08-05", "allenamento", dieci)], + presenze: { + d1: Object.fromEntries(dieci.map((id, i) => [id, i === 0 ? "assente" : "presente"])), + }, + }; + const ob1 = trova(obiettiviSquadra(giocatori, novePresenti, OGGI_AGOSTO), "o1"); + assert.equal(ob1.valore, 90, "9 su 10 = 90%"); + assert.equal(progressoObiettivo(ob1), 100, "90% su target 90% = completato"); +} + // --- o1: si azzera a ogni cambio mese, in base agli eventi a calendario ------ { // Stesso evento/presenze: "in mese" a settembre, "fuori mese" se letto da agosto. @@ -529,6 +692,7 @@ const o = (valore: number, target: number): ObiettivoSquadra => ({ unita: "%", emoji: "🎯", impatto: "i", + dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" }, }); assert.equal(progressoObiettivo(o(0, 10)), 0); assert.equal(progressoObiettivo(o(5, 10)), 50); @@ -558,4 +722,17 @@ assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.") assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo."); assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo."); +// --- dettaglio: ogni obiettivo spiega tutto, senza campi vuoti --------------------- +for (const ob of obiettiviSquadra(giocatori, contestoVuoto)) { + const d = ob.dettaglio; + for (const campo of [d.comeSiCalcola, d.periodo, d.fonte, d.esempio]) { + assert.ok(campo.trim().length > 0, `${ob.id}: testo di dettaglio vuoto`); + } + assert.ok(d.conta.length > 0, `${ob.id}: manca «cosa conta»`); + assert.ok(d.nonConta.length > 0, `${ob.id}: manca «cosa non conta»`); + if (ob.id === "o1" || ob.id === "o7") { + assert.ok(d.perse && d.perse.length > 0, `${ob.id}: manca «presenza persa»`); + } +} + console.log("obiettivi: ok"); From e7ae2c188469aef5a7e7da5342d55c6440957553 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 11:39:11 +0200 Subject: [PATCH 5/8] Toglie numero e data tessera dalla dashboard (DD-048); docs allineate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spariscono dalla scheda del giocatore in /admin il blocco «Tesseramento CSI» e il salvataggio, il badge «Tesserato/Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non cambia. Le colonne numero_tessera e data_tessera restano a database, non più usate; nessuna migration. Aggiornati PROJECT_STATE, CHANGELOG, ARCHITECTURE, DATABASE, ROADMAP, i moduli profilo-giocatore, squadra e obiettivi-squadra, test/README e l'indice delle decisioni. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 4 ++ docs/ARCHITECTURE.md | 2 +- docs/CHANGELOG.md | 3 ++ docs/DATABASE.md | 2 +- docs/ROADMAP.md | 4 +- docs/decisions/DD-048.md | 37 ++++++++++++++++ docs/decisions/README.md | 1 + docs/modules/obiettivi-squadra.md | 7 ++- docs/modules/profilo-giocatore.md | 11 ++--- docs/modules/squadra.md | 13 +++--- src/lib/giocatori-squadra.ts | 34 -------------- src/routes/admin.tsx | 73 +------------------------------ test/README.md | 2 +- 13 files changed, 65 insertions(+), 128 deletions(-) create mode 100644 docs/decisions/DD-048.md diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index bb56ea7..b94e869 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -18,6 +18,10 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in `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. +- **Su `develop`, non ancora rilasciato:** obiettivi cliccabili con card di dettaglio, presenze del mese + calcolate sui convocati, obiettivi «Presenze collettive» e «evento di squadra al mese» (ora dell'evento), + tessera CSI tolta dalla dashboard (DD-048), notifica di compleanno (DD-047, migration `m31`). Dettaglio in + [docs/CHANGELOG.md](docs/CHANGELOG.md). - **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 è su `develop`, non ancora su `main`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1a74348..fcffcc2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -85,7 +85,7 @@ quando il database non risponde. ## UI Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`, -`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche +`Section`, `StatTile`; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click. In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 958d218..1adedfa 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,6 +25,9 @@ versioni `0.y.z` erano pre-release. ### Modificato +- **Tessera CSI tolta dalla dashboard** — in Profili spariscono il blocco «Tesseramento CSI» (numero e data + tessera), il badge «Tesserato»/«Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non + cambia. Le colonne a database restano, non più usate (DD-048). Nessuna migration. - **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Nessuna migration. - **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 444af8a..cd6de2f 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -59,7 +59,7 @@ Colonne di `giocatori_squadra` che meritano una nota: | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. | | `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. | -| `numero_tessera`, `data_tessera` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include tra i campi bloccati per chi reclama il proprio slot. | +| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. | | `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). | | `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 462f19e..eacf5f4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,8 +29,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico (caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati resta un'estensione futura -- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già - tesserato (numero e data di tessera) +- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data + di tessera è stato tolto dalla dashboard (DD-048) - [x] Collegamento CSI (stagione 2025/26) - [x] Classifica automatica (campionato e Coppa) - [x] Risultati campionato diff --git a/docs/decisions/DD-048.md b/docs/decisions/DD-048.md new file mode 100644 index 0000000..1d3948c --- /dev/null +++ b/docs/decisions/DD-048.md @@ -0,0 +1,37 @@ +# DD-048 — Via numero e data tessera dalla dashboard + +**Data:** 6 ottobre 2026 +**Stato:** Accettata (nessuna migration) + +**Contesto** +La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data +tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app. + +**Decisione** + +1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva + tesseramento» e il salvataggio (`useSalvaTesseramento()`). +2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso: + il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra. +3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo + amministrativo: documento, certificato, dati personali. +4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate + e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori + già inseriti restano a database. + +**Alternative scartate** + +- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori + già inseriti; si può fare dopo senza toccare l'interfaccia. +- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e + `0/N`, un'informazione falsa. + +**Conseguenze** + +- L'admin non può più registrare né correggere la tessera dall'app. +- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza + uso in interfaccia: sono volutamente lasciati finché le colonne esistono. + +**Riesame** +Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una +migration e togliere i due campi dal tipo e dalla lettura. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 71c98c0..5fa5128 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -86,6 +86,7 @@ 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-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-040](DD-040.md) | Palloni e solleciti automatici | +| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-047](DD-047.md) | Notifica di compleanno | | [DD-046](DD-046.md) | Avviso certificati mancanti | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index a84df8c..3c4c0a5 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -2,7 +2,8 @@ **Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano, copertura test completa (unit + integration) su tutti e 10 gli obiettivi. -**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`) +**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`), +`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio) --- @@ -70,7 +71,9 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente (fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo ("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese) -seguono di conseguenza. +seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce +ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono +presenze perse. `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, diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index d6299ec..b9256c2 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -328,7 +328,6 @@ Per ogni giocatore, nella tab Profili, vengono mostrati. - Stato del profilo - Certificato medico - Documento di identità -- Stato tesseramento CSI (tesserato / da tesserare) Azioni disponibili. @@ -336,7 +335,6 @@ Azioni disponibili. - Scarica certificato - Scarica documento - Modifica dati squadra e dati personali del giocatore (DD-017) -- Registra numero e data della tessera CSI, una volta arrivata dal comitato - Scollega account, per liberare uno slot assegnato per errore - Aggiungi giocatore, per inserire un nuovo membro della squadra - Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra) @@ -362,12 +360,9 @@ Campi esportati. ## Tracciamento tesseramento -Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano -dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del -profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo -(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La -dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio -complessivo della squadra. +Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non +ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne +`numero_tessera` e `data_tessera` esistono ancora a database, senza uso. ## Completamento profilo diff --git a/docs/modules/squadra.md b/docs/modules/squadra.md index 481f87f..65cf7c6 100644 --- a/docs/modules/squadra.md +++ b/docs/modules/squadra.md @@ -55,13 +55,12 @@ specifica completa sta in [allenatore.md](allenatore.md). Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)): -| Azione | Hook | Effetto | -| ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) | -| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) | -| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta | -| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo | -| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) | +| Azione | Hook | Effetto | +| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) | +| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) | +| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta | +| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo | Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo accesso con Google, per corrispondenza email diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index 7df6258..99d5448 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -222,40 +222,6 @@ export function useSalvaDatiSquadra() { }); } -/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */ -export type DatiTesseramento = Pick; - -/** - * Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo: - * il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi - * tesserato. - */ -export function useSalvaTesseramento() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => { - const dati = { - numero_tessera: input.dati.numeroTessera?.trim() || null, - data_tessera: input.dati.dataTessera || null, - }; - const { error } = await supabaseNuoveTabelle - .from("giocatori_squadra") - .update(dati) - .eq("id", input.giocatoreId); - if (error) throw error; - return { - giocatoreId: input.giocatoreId, - dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera }, - }; - }, - onSuccess: (input) => { - queryClient.setQueryData(SQUADRA_KEY, (prec) => - (prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)), - ); - }, - }); -} - /** * Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1. * L'id (`g`) non è generato dal database: va calcolato con `prossimoIdGiocatore` diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index b61ba01..038a0ee 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { - BadgeCheck, Bell, BellOff, ChevronDown, @@ -33,11 +32,9 @@ import { useGiocatoriSquadra, useImpostaAttivo, useSalvaDatiSquadra, - useSalvaTesseramento, useScollegaAccount, validaDatiSquadra, type DatiSquadra, - type DatiTesseramento, type GiocatoreSquadra, } from "@/lib/giocatori-squadra"; import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili"; @@ -110,18 +107,11 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi const { righe } = useGiocatoriSquadra(); const salvaSquadra = useSalvaDatiSquadra(); const salvaProfilo = useSalvaProfilo(); - const salvaTesseramento = useSalvaTesseramento(); const scollega = useScollegaAccount(); const impostaAttivo = useImpostaAttivo(); const [datiSquadra, setDatiSquadra] = useState(null); const [bozza, setBozza] = useState(null); - const [tesseramento, setTesseramento] = useState(null); - - const tesseramentoCorrente: DatiTesseramento = tesseramento ?? { - numeroTessera: g.numeroTessera, - dataTessera: g.dataTessera, - }; const squadraCorrente: DatiSquadra = datiSquadra ?? { nome: g.nome, @@ -131,7 +121,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi email: g.email, tipo: g.tipo, }; - // L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034). + // L'allenatore non ha numero né ruolo in campo (DD-034). const allenatore = isAllenatore(g); const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id); @@ -167,16 +157,6 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi } } - async function confermaTesseramento() { - try { - await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente }); - setTesseramento(null); - toast.success("Tesseramento aggiornato"); - } catch (e) { - toast.error(messaggioErrore(e, "Salvataggio non riuscito")); - } - } - async function confermaProfilo() { try { await salvaProfilo.mutateAsync(profiloCorrente); @@ -269,46 +249,6 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi Salva dati squadra - {allenatore ? null : ( - <> -

Tesseramento CSI

-
- - - setTesseramento({ - ...tesseramentoCorrente, - numeroTessera: e.target.value || null, - }) - } - className={classiInput} - /> - - - - setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null }) - } - className={classiInput} - /> - -
- - - )} - {allenatore ? ( <>

Dati personali

@@ -486,15 +426,6 @@ function SchedaGiocatore({ stato={certificato} path={profilo?.certificatoPath ?? null} /> - - - {g.numeroTessera ? "Tesserato" : "Da tesserare"} - )} @@ -743,7 +674,6 @@ function Dashboard() { statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) === "valido", ).length; - const tesserati = attivi.filter((g) => g.numeroTessera).length; const contenutoSquadra = ( <> @@ -755,7 +685,6 @@ function Dashboard() { label="Certificati validi" hint="non scaduti" /> - diff --git a/src/components/crapp/CelebrazioneBadge.tsx b/src/components/crapp/CelebrazioneBadge.tsx index e6df35d..a7d2497 100644 --- a/src/components/crapp/CelebrazioneBadge.tsx +++ b/src/components/crapp/CelebrazioneBadge.tsx @@ -51,11 +51,11 @@ export function CelebrazioneBadge() { {notifica.emoji}
-

+

{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}

{notifica.titolo}

-

{notifica.testo}

+

{notifica.testo}

diff --git a/src/components/crapp/CollezioneBadge.tsx b/src/components/crapp/CollezioneBadge.tsx index 32bbdf1..eb604ff 100644 --- a/src/components/crapp/CollezioneBadge.tsx +++ b/src/components/crapp/CollezioneBadge.tsx @@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
{meta ? ( - {meta.label} + {meta.label} ) : null}
-

{b.def.nome}

-

+

{b.def.nome}

+

{b.valore} {b.def.unita}

{b.prossimaSoglia ? ( <> -

{mancanoPer(b)}

-

{microcopyBadge(b)}

+

{mancanoPer(b)}

+

{microcopyBadge(b)}

) : ( -

{microcopyBadge(b)}

+

{microcopyBadge(b)}

)} ); @@ -90,13 +90,15 @@ function SocialDrawer({
{cat.nome} - + {cat.descrizione}
-

Vinto

+

+ Vinto +

{conteggio}{" "} @@ -109,7 +111,7 @@ function SocialDrawer({ @@ -139,7 +141,7 @@ export function CollezioneBadge({

-

+

Collezione badge

@@ -147,7 +149,7 @@ export function CollezioneBadge({ /{c.totali}

-

+

completata

@@ -156,12 +158,12 @@ export function CollezioneBadge({ {vicino ? (
-

+

Prossimo traguardo

-

{vicino.def.nome}

+

{vicino.def.nome}

-

+

{mancanoPer(vicino)} · {microcopyBadge(vicino)}

@@ -169,7 +171,7 @@ export function CollezioneBadge({ {c.sbloccati.length > 0 ? (
-

+

Sbloccati ({c.sbloccati.length})

@@ -184,7 +186,7 @@ export function CollezioneBadge({ {c.inProgresso.length > 0 ? (
-

+

In progresso ({c.inProgresso.length})

@@ -199,7 +201,7 @@ export function CollezioneBadge({ {socialVinti.length > 0 ? (
-

+

Votati dai compagni

@@ -207,8 +209,8 @@ export function CollezioneBadge({

{cat.emoji}

-

{cat.nome}

-

+

{cat.nome}

+

Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}

@@ -219,7 +221,7 @@ export function CollezioneBadge({ ) : null}
-

+

Badge segreti

@@ -229,8 +231,8 @@ export function CollezioneBadge({
-

{b.def.nome}

-

+

{b.def.nome}

+

{b.def.celebrazione ?? "Badge segreto sbloccato."}

@@ -243,8 +245,10 @@ export function CollezioneBadge({ className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center" > -

???

-

Badge segreto da scoprire

+

+ ??? +

+

Badge segreto da scoprire

))}
diff --git a/src/components/crapp/DettaglioCsi.tsx b/src/components/crapp/DettaglioCsi.tsx index f7ba10c..c2d934f 100644 --- a/src/components/crapp/DettaglioCsi.tsx +++ b/src/components/crapp/DettaglioCsi.tsx @@ -49,7 +49,7 @@ export function MetaPartitaCsi({ .join(" · "); if (!voci && !link) return null; return ( -
+
{voci ? {voci} : null} {link ? ( -

+

{squadra.squadra}

{squadra.titolari.map((g, i) => ( -
- +
+ {g.numero} {g.nome} - {g.ruolo ? {g.ruolo} : null} + {g.ruolo ? {g.ruolo} : null}
))}
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {

{squadra.panchina.map((g, i) => ( -
+
{g.numero} {g.nome}
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) { ) : null} {squadra.staff.length > 0 ? ( -
+
{squadra.staff.map((s, i) => (
{s.nome} · {s.ruolo} @@ -121,7 +121,7 @@ function BarraPrecedenti({ }) { const totale = noi + avversario || 1; return ( -
+
{noi} {label} @@ -139,7 +139,7 @@ const NOME_NOI = "CRAP Volley"; /** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */ function TestataSquadre({ avversario }: { avversario: string }) { return ( -
+
{NOME_NOI} {avversario}
@@ -158,7 +158,7 @@ function BarraProbabilita({ const favoritaNoi = noi >= avversarioPct; return (
-

+

Probabilità di vittoria (calcolo CSI):{" "} {favoritaNoi ? NOME_NOI : avversario}{" "} favorita al {Math.max(noi, avversarioPct).toFixed(0)}%. @@ -171,13 +171,13 @@ function BarraProbabilita({

{noi.toFixed(0)}%
{avversarioPct.toFixed(0)}% @@ -197,7 +197,7 @@ function BloccoPrecedenti({ return (
-

+

{precedenti.totale > 0 ? `${precedenti.totale} precedenti in archivio sul portale CSI.` : "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."} @@ -248,20 +248,22 @@ export function DettaglioCsiEsteso({ const { data, isLoading } = useCsiPartita(matchId); if (isLoading) { - return

Carico i dati dal CSI…

; + return ( +

Carico i dati dal CSI…

+ ); } if (!data || (!data.formazioni && !data.precedenti)) return null; return (
{data.giornata || data.nota ? ( -

+

{[data.giornata, data.nota].filter(Boolean).join(" · ")}

) : null} {data.formazioni ? ( -
+
Formazioni
@@ -272,7 +274,7 @@ export function DettaglioCsiEsteso({ ) : null} {data.precedenti ? ( -
+
Scontri diretti
diff --git a/src/components/crapp/EventoCard.tsx b/src/components/crapp/EventoCard.tsx index 7b6cb51..36be015 100644 --- a/src/components/crapp/EventoCard.tsx +++ b/src/components/crapp/EventoCard.tsx @@ -101,10 +101,10 @@ export function EventoCard({
-

{evento.titolo}

-

{evento.luogo}

+

{evento.titolo}

+

{evento.luogo}

-
+
{etichettaData(evento.data)}
@@ -139,13 +139,13 @@ export function EventoCard({
{tipo.label} - + {etichettaData(evento.data)} {linkTo ? : null} @@ -153,10 +153,10 @@ export function EventoCard({

{evento.titolo}

{sottotitoloPartita ? ( -

{sottotitoloPartita}

+

{sottotitoloPartita}

) : null} -
+
{evento.ora} @@ -174,12 +174,12 @@ export function EventoCard({
{noteCard ? ( -

📝 {noteCard}

+

📝 {noteCard}

) : null} {metaStato ? (

{metaStato.emoji} diff --git a/src/components/crapp/ObiettivoDrawer.tsx b/src/components/crapp/ObiettivoDrawer.tsx index 2662f1b..90d7803 100644 --- a/src/components/crapp/ObiettivoDrawer.tsx +++ b/src/components/crapp/ObiettivoDrawer.tsx @@ -17,12 +17,12 @@ import { function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) { return (

-

+

{titolo}

    {voci.map((v) => ( -
  • +
  • {segno} @@ -37,10 +37,10 @@ function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno function Blocco({ titolo, testo }: { titolo: string; testo: string }) { return (
    -

    +

    {titolo}

    -

    {testo}

    +

    {testo}

    ); } @@ -58,7 +58,7 @@ export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
    {o.titolo} - + {o.descrizione}
    @@ -66,12 +66,12 @@ export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
    -

    +

    Stato attuale

    @@ -83,10 +83,10 @@ export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) { {o.unita}

    -

    +

    {pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}

    -

    {microcopyObiettivo(o)}

    +

    {microcopyObiettivo(o)}

    @@ -101,7 +101,7 @@ export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) { -

    +

    {o.impatto}

    @@ -130,7 +130,7 @@ export function ObiettivoDrawer({ diff --git a/src/components/crapp/Pagelle.tsx b/src/components/crapp/Pagelle.tsx index 484074e..9d24f22 100644 --- a/src/components/crapp/Pagelle.tsx +++ b/src/components/crapp/Pagelle.tsx @@ -45,19 +45,19 @@ export function Pagelle({ return (
    -

    +

    Pagelle anonime

    - + {chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
    -

    +

    Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.

    {isPending ? ( -

    +

    Carico le pagelle…

    ) : ( @@ -70,15 +70,17 @@ export function Pagelle({ return (
    - + - {g.nome} - + + {g.nome} + + {media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"} {sonoIo ? ( - + Sei tu ) : chiuse ? ( @@ -89,7 +91,7 @@ export function Pagelle({ disabled={!io || vota.isPending} onClick={() => setApertoPer(aperto ? null : g.id)} className={cn( - "shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50", + "shrink-0 rounded-full px-3 py-1 text-[13px] font-bold uppercase transition-colors disabled:opacity-50", mio !== undefined ? "bg-accent text-accent-foreground" : "bg-card text-foreground", @@ -107,7 +109,7 @@ export function Pagelle({ type="button" onClick={() => invia(g.id, v)} className={cn( - "rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90", + "rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90", mio === v ? "bg-accent text-accent-foreground" : "bg-card text-foreground", diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx index 17cfdee..60bc5e6 100644 --- a/src/components/crapp/ProfiloAmministrativo.tsx +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -27,7 +27,7 @@ const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { return (
    -

    {titolo}

    +

    {titolo}

    {completa ? : null}
    ); @@ -67,7 +67,7 @@ function CampoFile({ return (
    - + input.current?.click()} disabled={inCorso} - className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60" + className="premi rounded-xl bg-primary px-3 py-2 text-[13px] font-bold text-primary-foreground disabled:opacity-60" > {inCorso ? : path ? "Sostituisci" : "Carica"} @@ -336,7 +336,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) { style={{ width: `${perc}%` }} />
    - + {perc}%
    @@ -366,7 +366,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) { type="button" onClick={() => void salva()} disabled={!sporco || inCorso} - className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {inCorso ? : null} {sporco ? "Salva" : "Salvato"} @@ -430,7 +430,7 @@ export function ProfiloAmministrativo({ style={{ width: `${perc}%` }} />
    - + {perc}%
@@ -472,7 +472,7 @@ export function ProfiloAmministrativo({ type="button" onClick={salvaBozza} disabled={!sporco || salva.isPending} - className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {salva.isPending ? : null} {sporco ? "Salva" : "Salvato"} @@ -486,7 +486,9 @@ export function ProfiloAmministrativo({ {perc}%} + azione={ + {perc}% + } > {corpo} @@ -521,10 +523,10 @@ export function CompletaProfilo({ className="premi block rounded-3xl bg-card p-4 shadow-card" >
- + Completa il tuo profilo - {perc}% + {perc}%
-

+

{allenatore ? "Data e luogo di nascita, telefono ed email." : "Documento e certificato medico servono per il tesseramento CSI."} diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index f02d065..ad9c9cf 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -70,7 +70,7 @@ export function RosaPresenze({

-

+

Hanno risposto {risposteN}/{rosa.length}

{perc}% @@ -84,7 +84,7 @@ export function RosaPresenze({ 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground", )} > @@ -92,14 +92,14 @@ export function RosaPresenze({ ); })} - + ❔ {mancanti.length} da rispondere
{io ? (
-

+

La tua risposta

@@ -114,7 +114,7 @@ export function RosaPresenze({ salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s }) } className={cn( - "rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50", + "rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50", attivo ? cn(statoMeta[s].className, "border-transparent shadow-card") : "bg-background text-muted-foreground", @@ -133,7 +133,7 @@ export function RosaPresenze({ type="button" onClick={sollecita} disabled={sollecito || daSollecitare === 0 || passato} - className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50" + className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50" > {sollecito ? ( @@ -146,7 +146,7 @@ export function RosaPresenze({ {isPending ? ( -

+

Carico le risposte…

) : null} @@ -194,16 +194,16 @@ function Gruppo({ attenzione && "border border-dashed border-warning", )} > -

+

{titolo} · {n} {attenzione ? : null}

{lista.map((g) => (
- - {g.nome} - {g.ruolo} + + {g.nome} + {g.ruolo}
))}
diff --git a/src/components/crapp/ScoutEntry.tsx b/src/components/crapp/ScoutEntry.tsx index 6caa845..d67cb79 100644 --- a/src/components/crapp/ScoutEntry.tsx +++ b/src/components/crapp/ScoutEntry.tsx @@ -37,7 +37,7 @@ export function ScoutEntry({ {occupato ? : } {titolo} - {sottotitolo} + {sottotitolo} {disponibile ? : null} diff --git a/src/components/crapp/SerieCard.tsx b/src/components/crapp/SerieCard.tsx index 474e841..395ae44 100644 --- a/src/components/crapp/SerieCard.tsx +++ b/src/components/crapp/SerieCard.tsx @@ -28,8 +28,8 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
-

{s.def.label}

-

{s.def.descrizione}

+

{s.def.label}

+

{s.def.descrizione}

-

+

{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""} {s.messaggio}

@@ -61,17 +61,17 @@ export function SerieHome({ g }: { g: Giocatore }) {
-

+

Serie {top.def.label.toLowerCase()}: {top.valore}

-

{top.messaggio}

+

{top.messaggio}

{serieGiocatore(g).map((s) => (

{s.valore}

-

+

{s.def.label}

diff --git a/src/components/crapp/SondaggioCacche.tsx b/src/components/crapp/SondaggioCacche.tsx index 59a0213..ef5b58a 100644 --- a/src/components/crapp/SondaggioCacche.tsx +++ b/src/components/crapp/SondaggioCacche.tsx @@ -81,10 +81,10 @@ export function SondaggioCacche({ if (!sondaggioAperto(dataEvento, oraEvento)) { return ( -

+

💩 Sondaggio pre-partita

-

+

{sondaggioTerminato(dataEvento, oraEvento) ? "Sondaggio chiuso: era aperto fino al fischio d'inizio." : "Apre alle 8:00 del giorno della partita: riceverai una notifica."} @@ -96,14 +96,14 @@ export function SondaggioCacche({ return (

-

+

💩 Sondaggio pre-partita

- + {dellaPartita.length} risposte
-

+

Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.

@@ -115,7 +115,7 @@ export function SondaggioCacche({ disabled={!io || salva.isPending} onClick={() => rispondi(n)} className={cn( - "min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50", + "min-w-11 rounded-xl px-3 py-2 text-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50", mia?.quantita === n ? "bg-accent text-accent-foreground shadow-pop" : "bg-secondary text-foreground", @@ -126,7 +126,7 @@ export function SondaggioCacche({ ))}
-
+
Media squadra {media} @@ -145,7 +145,7 @@ export function SondaggioCacche({ type="button" onClick={avvisaTutti} disabled={avviso} - className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50" + className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50" > {avviso ? : } Avvisa tutti del sondaggio diff --git a/src/components/crapp/TurnoPalloni.tsx b/src/components/crapp/TurnoPalloni.tsx index ff4bfb5..59e289d 100644 --- a/src/components/crapp/TurnoPalloni.tsx +++ b/src/components/crapp/TurnoPalloni.tsx @@ -74,16 +74,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) { - + Palloni - + {isPending || assegna.isPending ? ( ) : null} {giocatore ? nomeCompleto(giocatore) : "Da assegnare"} {proposto && giocatore ? ( - + proposto ) : null} @@ -100,11 +100,11 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) { type="button" onClick={() => scegli(g.id)} className={cn( - "flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors", + "flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[15px] transition-colors", g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary", )} > - + {nomeCompleto(g)} {g.id === id ? : null} @@ -117,7 +117,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) { type="button" onClick={avvisa} disabled={avviso} - className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50" + className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-[13px] font-bold uppercase text-foreground disabled:opacity-50" > {avviso ? ( diff --git a/src/components/crapp/VotazioneMvp.tsx b/src/components/crapp/VotazioneMvp.tsx index 3de0a90..08adfb7 100644 --- a/src/components/crapp/VotazioneMvp.tsx +++ b/src/components/crapp/VotazioneMvp.tsx @@ -46,10 +46,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { if (!votoMvpAperto(evento.data, evento.ora)) { return (
-

+

Voto MVP

-

+

Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.

@@ -75,20 +75,20 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { return (
-

+

Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}

-

+

{testa && !pareggio ? ( {testa.nome} ({testa.voti}) @@ -100,9 +100,9 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { )}

{mio ? ( -

Hai votato {mio.votato_nome}

+

Hai votato {mio.votato_nome}

) : !puoVotare ? ( -

+

Vota chi era presente a questa partita.

) : null} @@ -120,7 +120,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { disabled={vota.isPending} onClick={() => invia(g.id, nomeCompleto(g))} className={cn( - "truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors", + "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", mio?.votato_id === g.id ? "bg-accent text-accent-foreground" : "bg-card text-foreground", @@ -133,7 +133,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { ) : conteggio.length > 0 ? (
{conteggio.map((c) => ( - + {c.nome} · {c.voti} ))} diff --git a/src/components/crapp/VotoSocial.tsx b/src/components/crapp/VotoSocial.tsx index 60468ab..c38f63d 100644 --- a/src/components/crapp/VotoSocial.tsx +++ b/src/components/crapp/VotoSocial.tsx @@ -51,7 +51,7 @@ export function VotoSocial({ matchId }: { matchId: string }) { return (
-
+
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria @@ -79,14 +79,16 @@ export function VotoSocial({ matchId }: { matchId: string }) { {cat.emoji} - {cat.nome} - + + {cat.nome} + + {mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione} @@ -105,7 +107,7 @@ export function VotoSocial({ matchId }: { matchId: string }) { disabled={vota.isPending} onClick={() => invia(cat.id, g.id, nomeCompleto(g))} className={cn( - "truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors", + "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", mio?.votato_id === g.id ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground", @@ -118,17 +120,17 @@ export function VotoSocial({ matchId }: { matchId: string }) { ) : (
{totale === 0 ? ( -

+

Nessun voto ancora: apri e scegli un compagno.

) : vincitore ? ( -

+

{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}

) : ( -

+

Parità con {totale} voti: servono altri voti per assegnare il badge.

)} diff --git a/src/components/crapp/ui-bits.tsx b/src/components/crapp/ui-bits.tsx index 9080813..3310eda 100644 --- a/src/components/crapp/ui-bits.tsx +++ b/src/components/crapp/ui-bits.tsx @@ -107,13 +107,13 @@ function RigaNotifica({ const arrivo = info.offset.x + proietta(info.velocity.x); if (arrivo < -60) onElimina(notifica.id); }} - className="touch-pan-y bg-card p-3 pr-8 text-sm" + className="touch-pan-y bg-card p-3 pr-8 text-[15px]" >

{notifica.titolo}

{notifica.corpo ? (

{notifica.corpo}

) : null} -

{tempoRelativo(notifica.creataIl)}

+

{tempoRelativo(notifica.creataIl)}

{azione ?? } @@ -338,7 +338,7 @@ export function SezioneTendina({ * parità di padding — un'altezza esplicita allinea tutti i campi tra loro. */ export const classiInput = - "h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm"; + "h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]"; /** * Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label @@ -349,7 +349,7 @@ export const classiInput = export function Campo({ label, children }: { label: string; children: ReactNode }) { return (
); } diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index fc12725..b3a1e2c 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -78,7 +78,7 @@ const DrawerDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 3171510..ea89f5c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -27,13 +27,13 @@ function NotFoundComponent() {

404

Pagina non trovata

-

+

La pagina che cerchi non esiste o è stata spostata.

Torna alla home @@ -53,7 +53,7 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })

Questa pagina non si è caricata

-

+

Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.

@@ -63,13 +63,13 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) router.invalidate(); reset(); }} - className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground" + className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground" > Riprova Torna alla home diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 038a0ee..1c9226d 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -184,7 +184,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi return (
-

Dati squadra

+

Dati squadra

Salva dati squadra {allenatore ? ( <> -

Dati personali

+

Dati personali

setBozza({ ...profiloCorrente, ...patch })} @@ -268,12 +268,12 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi type="button" onClick={confermaProfilo} disabled={!bozza || salvaProfilo.isPending} - className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50" + className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50" > Salva dati personali -
+
{g.authUserId ? "Account collegato" : "Nessun account collegato"} @@ -344,7 +344,7 @@ function Documento({ onClick={scarica} disabled={!path || inCorso} className={cn( - "premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60", + "premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase disabled:opacity-60", statoClasse[stato], )} > @@ -382,12 +382,12 @@ function SchedaGiocatore({ return ( @@ -489,7 +489,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) { return (
-

+

{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}

@@ -563,7 +563,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) { setAperto(false); setDati(datiVuoti); }} - className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase" + className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-[13px] font-bold uppercase" > Annulla @@ -571,7 +571,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) { type="button" onClick={() => void conferma()} disabled={aggiungi.isPending} - className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50" + className="premi flex-1 rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50" > Aggiungi @@ -598,7 +598,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {

{nomeCompleto(g)}

-

+

{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}

@@ -606,7 +606,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) { type="button" onClick={() => void riattiva()} disabled={impostaAttivo.isPending} - className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50" + className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-[13px] font-bold text-success-foreground disabled:opacity-50" > Riattiva @@ -653,7 +653,7 @@ function Dashboard() { <>
-

+

Riservata agli amministratori della squadra.

@@ -689,7 +689,7 @@ function Dashboard() { @@ -698,7 +698,7 @@ function Dashboard() { ); const contenutoProfili = isPending ? ( -

+

Caricamento…

) : ( @@ -715,7 +715,7 @@ function Dashboard() { /> ))} {allenatori.length > 0 ? ( -

Allenatori

+

Allenatori

) : null} {allenatori.map((g, i) => ( setDestinatarioMessaggio({ nome: "tutta la squadra" })} - className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop" + className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop" > Invia messaggio a tutti @@ -764,7 +764,7 @@ function Dashboard() { )}

@@ -784,7 +784,7 @@ function Dashboard() {

) : ( -

+

Caricamento…

); @@ -838,7 +838,7 @@ function Dashboard() { type="button" onClick={inviaMessaggio} disabled={inviaNotifica.isPending || !messaggio.trim()} - className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {inviaNotifica.isPending ? ( @@ -850,7 +850,7 @@ function Dashboard() { diff --git a/src/routes/allenamento.$id.tsx b/src/routes/allenamento.$id.tsx index 72bf46e..2a6e031 100644 --- a/src/routes/allenamento.$id.tsx +++ b/src/routes/allenamento.$id.tsx @@ -43,7 +43,9 @@ function AllenamentoDetail() { if (!evento) { return (
-

Allenamento non trovato

+

+ Allenamento non trovato +

); } @@ -59,14 +61,14 @@ function AllenamentoDetail() {
-

+

Allenamento di squadra

{evento.titolo}

-
+
{evento.ora} @@ -76,12 +78,12 @@ function AllenamentoDetail() {
{evento.note ? ( -

+

{evento.note}

) : null} -
+
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index c20cda0..ab3dd7d 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -107,34 +107,34 @@ function Benvenuto() { {!pronta ? null : !utenteId ? ( <> -

+

Accedi con il tuo account Google per collegare il profilo giocatore.

) : erroreCollegamento ? ( <> -

+

Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo account.

) : inAttesaCollegamento ? ( -

+

Ti stiamo collegando al tuo profilo...

) : null} diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index 3a35251..d8e0120 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -119,7 +119,7 @@ function Calendario() {
-
+
{giorniIT.map((g, i) => ( {g} ))} @@ -192,7 +192,7 @@ function Calendario() { onClick={haEventi ? () => apriGiorno(giorno) : undefined} style={sfondo ? { backgroundImage: sfondo } : undefined} className={cn( - "relative grid aspect-square place-items-center rounded-xl text-sm font-semibold", + "relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold", tipo === "partita" && "bg-accent text-accent-foreground", tipo === "allenamento" && "bg-training text-training-foreground", tipo === "evento" && "bg-warning text-warning-foreground", @@ -224,10 +224,10 @@ function Calendario() {
-

+

Scorri a destra o sinistra per cambiare mese.

-
+
Partita @@ -248,7 +248,7 @@ function Calendario() {
Gestisci eventi @@ -263,7 +263,7 @@ function Calendario() { return ; }) ) : ( -

+

Nessun evento in programma

)} @@ -275,7 +275,7 @@ function Calendario() { {compleanniMese.length > 0 ? ( compleanniMese.map((c) => ) ) : ( -

+

Nessun compleanno in {mesiIT[mese]!.toLowerCase()}

)} @@ -300,7 +300,7 @@ function Calendario() { return ; }) ) : ( -

+

Nessun evento in questa data

)} diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index c0208ba..a06abed 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -82,7 +82,7 @@ export const Route = createFileRoute("/classifica")({ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) { return (
-
+
# Squadra G @@ -90,7 +90,7 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s Pt
{righe.length === 0 ? ( -

{vuoto}

+

{vuoto}

) : ( righe.map((r) => { const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; @@ -98,14 +98,14 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
{r.pos} {r.squadra} - {r.giocate} - + {r.giocate} + {r.setFatti}:{r.setSubiti} {r.punti} @@ -171,7 +171,7 @@ function Classifica() { <>
{classificaCoppa.length > 0 ? ( <> -

Coppa

+

Coppa

-

Girone

+

Girone

) : null} @@ -204,14 +204,14 @@ function Classifica() { const contenutoStorico = useMemo( () => tuttiMatch.length === 0 ? ( -

+

Nessuna partita disponibile.

) : (
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
-

Stagione {stagione}

+

Stagione {stagione}

{match.map((m) => { const vinta = m.setNostri > m.setLoro; const eventoId = eventoIdPerData.get(m.data); @@ -237,7 +237,7 @@ function Classifica() { logoAvversario={m.logoAvversario} avversario={m.avversario} /> -

{casa.nome}

+

{casa.nome}

-

{trasferta.nome}

+

{trasferta.nome}

-

+

{formatData(m.data)} · MVP {m.mvp || "da votare"} {m.scout ? " · scoutata" : ""}

@@ -278,7 +278,7 @@ function Classifica() { @@ -287,7 +287,7 @@ function Classifica() { ); })} {eventoId && !m.mvp ? ( - + Vota MVP ) : null} diff --git a/src/routes/eventi.tsx b/src/routes/eventi.tsx index 8f485c7..0bfd065 100644 --- a/src/routes/eventi.tsx +++ b/src/routes/eventi.tsx @@ -91,7 +91,7 @@ function GestioneEventi() { <>
-

+

Solo i referenti della squadra possono creare o modificare gli eventi.

@@ -207,7 +207,7 @@ function GestioneEventi() {
-
+
{giorniIT.map((g, i) => ( {g} ))} @@ -248,7 +248,7 @@ function GestioneEventi() { type="button" onClick={() => apriGiorno(giorno)} className={cn( - "relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90", + "relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold transition-transform active:scale-90", haEventi ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground", @@ -265,11 +265,11 @@ function GestioneEventi() {
{isPending ? ( -

+

Carico gli eventi…

) : isError ? ( -
+

Non sono riuscito a caricare gli eventi {error instanceof Error ? `: ${error.message}` : ""}. @@ -277,13 +277,13 @@ function GestioneEventi() {

) : ( -

+

Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere, modificare o eliminare.

@@ -303,7 +303,7 @@ function GestioneEventi() { type="button" onClick={() => aggiorna(daCategoria(t.id))} className={cn( - "rounded-full py-2 text-xs font-bold uppercase transition-colors", + "rounded-full py-2 text-[13px] font-bold uppercase transition-colors", categoriaEvento(bozza) === t.id ? "bg-card shadow-card text-foreground" : "text-muted-foreground", @@ -337,15 +337,17 @@ function GestioneEventi() { ) : (
- + Giorno
- {formatData(bozza.data)} + + {formatData(bozza.data)} + @@ -394,7 +396,7 @@ function GestioneEventi() { type="button" onClick={() => aggiorna({ casa: o.casa })} className={cn( - "flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors", + "flex-1 rounded-full py-2 text-[13px] font-bold uppercase transition-colors", bozza.casa === o.casa ? "bg-card shadow-card text-foreground" : "text-muted-foreground", @@ -433,7 +435,7 @@ function GestioneEventi() { }) } className={cn( - "truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors", + "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", scelto ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground", @@ -450,7 +452,7 @@ function GestioneEventi() { @@ -458,7 +460,7 @@ function GestioneEventi() { type="button" onClick={chiediConferma} disabled={salva.isPending} - className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {salva.isPending ? : null} Salva @@ -489,8 +491,8 @@ function GestioneEventi() { className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card" >
-

{e.titolo}

-

+

{e.titolo}

+

{e.ora} · {e.luogo || "luogo da definire"}

@@ -513,7 +515,7 @@ function GestioneEventi() {
)) ) : ( -

+

Nessun evento in programma

)} @@ -521,7 +523,7 @@ function GestioneEventi() { @@ -544,14 +546,14 @@ function GestioneEventi() { type="button" onClick={conferma} disabled={salva.isPending} - className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {salva.isPending ? : null} Conferma @@ -573,14 +575,14 @@ function GestioneEventi() { type="button" onClick={() => daEliminare && rimuovi(daEliminare.id)} disabled={elimina.isPending} - className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50" + className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-[15px] font-bold uppercase text-destructive-foreground disabled:opacity-50" > {elimina.isPending ? : null} Elimina @@ -606,7 +608,7 @@ function Interruttore({ type="button" onClick={onClick} className={cn( - "rounded-full px-3 py-1.5 text-xs font-bold uppercase transition-colors", + "rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors", attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground", )} > diff --git a/src/routes/evento.$id.tsx b/src/routes/evento.$id.tsx index 68a302e..a3bf16a 100644 --- a/src/routes/evento.$id.tsx +++ b/src/routes/evento.$id.tsx @@ -42,7 +42,7 @@ function EventoDetail() { if (!evento) { return (
-

Evento non trovato

+

Evento non trovato

); } @@ -58,14 +58,14 @@ function EventoDetail() {
-

+

Evento extra-campo

{evento.titolo}

-
+
{evento.ora} @@ -75,12 +75,12 @@ function EventoDetail() {
{evento.note ? ( -

+

{evento.note}

) : null} -
+
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 6506de1..83cde7c 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -86,7 +86,7 @@ function Index() { className="h-14 w-14 rounded-full shadow-pop ring-2 ring-primary-foreground/25" />
-

+

Ciao {nome.split(" ")[0]}

CRAP Volley

@@ -100,13 +100,13 @@ function Index() { >

{noi ? `${noi.pos}º` : "—"}

-

In classifica

+

In classifica

{noi ? `${noi.vinte}-${noi.perse}` : "—"}

-

Bilancio W-L

+

Bilancio W-L

{giocatore ? (
@@ -114,7 +114,7 @@ function Index() { {giocatore.streak}

-

Streak

+

Streak

) : null}
@@ -132,7 +132,7 @@ function Index() { azione={ Calendario @@ -141,7 +141,7 @@ function Index() { {prossimo ? ( ) : ( -

+

Nessun impegno in programma.

)} @@ -156,7 +156,7 @@ function Index() { return ; }) ) : ( -

+

Nient'altro da confermare: sei in pari.

)} @@ -171,7 +171,7 @@ function Index() { Storico @@ -183,8 +183,10 @@ function Index() { <>
-

CRAP Volley vs {ultima.avversario}

-

+

+ CRAP Volley vs {ultima.avversario} +

+

{ultima.casa ? "In casa" : "Trasferta"} {ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}

@@ -197,7 +199,7 @@ function Index() { {ultima.parziali.map((p, i) => ( {p[0]}-{p[1]} @@ -218,7 +220,7 @@ function Index() { ); })() ) : ( -

+

Nessun risultato disponibile.

)} @@ -231,7 +233,7 @@ function Index() { azione={ Tutti @@ -245,20 +247,20 @@ function Index() { className="block w-full text-left" > -
+
{obiettivo.emoji}{" "} {obiettivo.titolo}
-

+

Siamo al —{" "} {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.

-

+

{microcopyObiettivo(obiettivo)}

-

{obiettivo.impatto}

-

+

{obiettivo.impatto}

+

Come funziona

diff --git a/src/routes/partita-csi.$id.tsx b/src/routes/partita-csi.$id.tsx index 0409ef9..cbdd917 100644 --- a/src/routes/partita-csi.$id.tsx +++ b/src/routes/partita-csi.$id.tsx @@ -40,7 +40,7 @@ function PartitaCsiDetail() { if (!partita) { return (
-

Partita non trovata

+

Partita non trovata

); } @@ -69,7 +69,7 @@ function PartitaCsiDetail() { className="h-10 w-10" />
-

+

{partita.casa ? "In casa" : "Fuori casa"}

{partita.avversario}

@@ -93,7 +93,7 @@ function PartitaCsiDetail() { {partita.parziali.map(([noi, loro], i) => ( {noi}-{loro} diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 714f966..b79972d 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -60,7 +60,7 @@ function PartitaDetail() { if (!evento) { return (
-

Partita non trovata

+

Partita non trovata

); } @@ -118,7 +118,7 @@ function PartitaDetail() { ) : null}
-

+

{casa ? "In casa" : "Fuori casa"} {evento.campionato ? " · Campionato" : " · Amichevole"}

@@ -126,7 +126,7 @@ function PartitaDetail() {
-
+
{evento.ora} @@ -136,12 +136,12 @@ function PartitaDetail() {
{evento.note ? ( -

+

{evento.note}

) : null} -
+
Conferme: {presentiVeri}/{convocati.length}
@@ -163,7 +163,7 @@ function PartitaDetail() {
- CRAP Volley + CRAP Volley {match.setNostri} - {match.setLoro} - {avversario} + {avversario}
{match.parziali.map(([noi, loro], i) => ( @@ -183,7 +183,9 @@ function PartitaDetail() { className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2" > {noi} - Set {i + 1} + + Set {i + 1} + {loro}
))} @@ -213,7 +215,7 @@ function PartitaDetail() { ) ) : (
-

+

La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il risultato.

@@ -248,13 +250,13 @@ function PartitaDetail() { ].map((t) => (

{t.v}

-

+

{t.l}

))}
-

+

Dettaglio giocatori (uso interno allenatori)

@@ -264,7 +266,7 @@ function PartitaDetail() { return (
#{g.numero} {g.nome} @@ -285,7 +287,7 @@ function PartitaDetail() { csvScoutMatch(scout, rosa), ) } - className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop" + className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop" > Esporta CSV diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 9d4ce5b..a6af7e3 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -220,7 +220,7 @@ function CardAvatar({ id, fallback }: { id: string; fallback: string }) { @@ -312,11 +312,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { type="button" onClick={cambiaNotifiche} disabled={!supportate || inCorso} - className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60" + className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60" > Notifiche - + {supportate ? notifiche ? "Attive su questo dispositivo" @@ -339,11 +339,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { type="button" onClick={cambiaEmail} disabled={!email.pronta || email.imposta.isPending} - className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60" + className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60" > Email - + {email.attive ? "Le notifiche arrivano anche via email" : "Nessuna email, solo notifiche in app"} @@ -363,7 +363,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { {admin ? ( Dashboard amministratore @@ -373,7 +373,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml" target="_blank" rel="noopener noreferrer" - className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" + className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5" > Segnala un bug @@ -382,17 +382,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml" target="_blank" rel="noopener noreferrer" - className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5" + className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5" > Suggerisci una nuova funzionalità
-

CrAPP v{APP_VERSION}

+

CrAPP v{APP_VERSION}

{titolo}

-

{testo}

+

{testo}

{children}
@@ -138,7 +138,7 @@ function Scout() { @@ -156,7 +156,7 @@ function Scout() { if (ok) setControllo(true); else toast.error("Un altro compagno ha appena preso lo scout"); }} - className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" + className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" > {ripresa ? "Riprendi lo scout" : "Prendi il controllo"} @@ -313,7 +313,7 @@ function ScoutBoard({ <>
- + Scout live · Set {setCorrente}
@@ -321,14 +321,14 @@ function ScoutBoard({ type="button" onClick={annulla} disabled={azioniSet.length === 0} - className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40" + className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold disabled:opacity-40" > Annulla @@ -337,14 +337,14 @@ function ScoutBoard({
-

CRAP Volley

+

CRAP Volley

{puntiNoi}

{setNostri}-{setLoro}

-

+

{avversario || "Avversario"}

{puntiLoro}

@@ -356,7 +356,7 @@ function ScoutBoard({ {setChiusi.map((p, i) => ( {p[0]}-{p[1]} @@ -366,7 +366,7 @@ function ScoutBoard({
-

+

1. Tocca il giocatore

@@ -384,10 +384,10 @@ function ScoutBoard({ )} >

{g.numero}

-

{g.nome}

+

{g.nome}

@@ -400,7 +400,7 @@ function ScoutBoard({

-

+

2. Tocca l'azione

@@ -426,7 +426,7 @@ function ScoutBoard({ type="button" onClick={() => registra(tipo)} className={cn( - "rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95", + "rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95", azioniMeta[tipo].className, )} > @@ -438,11 +438,11 @@ function ScoutBoard({
-

+

Ultime azioni

{ultime.length === 0 ? ( -

Nessuna azione registrata.

+

Nessuna azione registrata.

) : (
    {ultime.map((a, i) => { @@ -451,14 +451,14 @@ function ScoutBoard({
  • {g ? `#${g.numero} ${g.nome}` : "Avversario"} @@ -474,14 +474,14 @@ function ScoutBoard({
    -