From f49351e5363845f0880a9037c506310580d8d524 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 4 Oct 2026 14:15:17 +0200 Subject: [PATCH] Avviso certificati: mostra anche chi non l'ha caricato (DD-046), versione 1.2.3 Il giocatore senza certificato vede una card rossa cliccabile, l'admin l'elenco dei mancanti sopra scaduti e in scadenza. Nessuna migration. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 6 +-- docs/CHANGELOG.md | 9 ++++ docs/ROADMAP.md | 6 +-- docs/decisions/DD-035.md | 5 ++- docs/decisions/DD-046.md | 33 ++++++++++++++ docs/decisions/README.md | 1 + docs/modules/profilo-giocatore.md | 38 ++++++++-------- package.json | 2 +- src/components/crapp/AvvisoCertificati.tsx | 31 ++++++++++--- src/lib/profili-core.ts | 33 +++++++++----- test/unit/profili-core.test.ts | 51 ++++++++++++++++++---- 11 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 docs/decisions/DD-046.md diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index ec4767b..052aefd 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -9,9 +9,9 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in ## Stato generale -- **Versione:** 1.2.2 su `develop` (su `main`, in produzione, resta la 1.2.1 fino al merge) — notifiche - su tre canali, avvisi di palloni (3 ore prima) e presenze (24 ore prima) una sola volta, niente - promemoria evento, niente foto tessera, avviso certificato a 30 giorni per il giocatore. +- **Versione:** 1.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni + (3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto + 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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8bdee37..5ff5524 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,15 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +## [1.2.3] - 2026-10-04 + +Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration. + +### Modificato + +- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa: + il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046). + ## [1.2.2] - 2026-10-01 Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 402191a..d609f27 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,9 +38,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). calcolata dal CSI - [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza stagione e badge ([specifica](modules/allenatore.md), DD-034) -- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la - scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio - ([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035) +- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la + scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio + ([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046) - [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati, da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo ([specifica](modules/notifiche-email.md), DD-036) diff --git a/docs/decisions/DD-035.md b/docs/decisions/DD-035.md index 64875cd..92b49b7 100644 --- a/docs/decisions/DD-035.md +++ b/docs/decisions/DD-035.md @@ -1,7 +1,7 @@ # DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin **Data:** 24 settembre 2026 -**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. +**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. [DD-046](DD-046.md) aggiunge l'avviso per chi non ha caricato il certificato. **Contesto** Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma @@ -40,7 +40,8 @@ certificato viene rinnovato. Specifica completa in esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti ([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). - Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto - dal widget «Completa il tuo profilo» e dalla tab Profili. + dal widget «Completa il tuo profilo» e dalla tab Profili. _Superata da [DD-046](DD-046.md): + anche i mancanti hanno un avviso, rosso._ **Conseguenze** diff --git a/docs/decisions/DD-046.md b/docs/decisions/DD-046.md new file mode 100644 index 0000000..1deb569 --- /dev/null +++ b/docs/decisions/DD-046.md @@ -0,0 +1,33 @@ +# DD-046 — Avviso certificati: anche chi non l'ha caricato + +**Data:** 4 ottobre 2026 +**Stato:** Accettata (modifica [DD-035](DD-035.md): il certificato mancante non è più escluso dall'avviso) + +**Contesto** +L'avviso certificati di DD-035 guarda solo la scadenza e salta chi non ha mai caricato il +certificato («è un problema diverso»). Per l'admin significa che chi non ha caricato nulla non +compare in Home, pur essendo un caso più grave di uno scaduto. + +**Decisione** + +1. Un giocatore in rosa **senza data o senza file** del certificato è **mancante**. +2. L'**admin** vede la card rossa «Certificati mancanti» con l'elenco, **sopra** scaduti e in + scadenza; il suo nome non compare se è anche giocatore. +3. Il **giocatore** mancante vede la card rossa «Certificato mancante», cliccabile verso il tab + documenti. Vale anche per l'admin che è giocatore. +4. Nessuna soglia: l'avviso resta finché il certificato non è caricato. Il rosso è `destructive`, + già nel tema; giallo e nero degli altri avvisi non cambiano. + +**Alternative scartate** + +- Stesso nero degli scaduti → non distingue «scaduto» da «mai caricato», che è più grave. +- Solo elenco admin → l'admin che è anche giocatore sarebbe l'unico a non vedere il proprio buco. + +**Conseguenze** + +- `avvisiCertificati()` restituisce anche `mancanti`; `avvisiCertificatiUtente()` anche + `personaleMancante`. +- Un giocatore appena entrato in rosa compare tra i mancanti finché non carica il file. + +**Riesame** +Se i giocatori nuovi riempiono l'elenco senza motivo, aggiungere un periodo di tolleranza. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8cdc861..67051e9 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-046](DD-046.md) | Avviso certificati mancanti | | [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore | | [DD-044](DD-044.md) | Via la foto tessera dal profilo | | [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore | diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 4dec796..d6299ec 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -117,12 +117,12 @@ staff. | Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` | | Admin | `GIORNI_AVVISO_CERTIFICATO = 7` | -| Condizione | Stato | Avviso | -| --------------------- | ----------- | ------------------ | -| `giorni > soglia` | valido | nessuno | -| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) | -| `giorni < 0` | scaduto | nero (`primary`) | -| data o file mancanti | mancante | nessuno | +| Condizione | Stato | Avviso | +| --------------------- | ----------- | ------------------------------ | +| `giorni > soglia` | valido | nessuno | +| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) | +| `giorni < 0` | scaduto | nero (`primary`) | +| data o file mancanti | mancante | rosso (`destructive`) (DD-046) | Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi. @@ -130,8 +130,10 @@ giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi - Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo. - L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo. -- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella - tab Profili di `/admin` e nel widget di completamento del giocatore. +- Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello + scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti), + l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio + nome. Non ha soglie: resta finché il file non è caricato. - Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`, cioè `attivo` e `tipo = 'giocatore'`). @@ -190,16 +192,16 @@ la Home non deve rompersi per un dato accessorio. #### Implementazione -| Pezzo | Ruolo | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 | -| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) | -| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto | -| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` | -| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) | -| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» | -| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card | -| `src/routes/index.tsx` | monta `` subito dopo `` | +| Pezzo | Ruolo | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 | +| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) | +| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto | +| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) | +| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) | +| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» | +| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card | +| `src/routes/index.tsx` | monta `` subito dopo `` | - "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da `new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima. diff --git a/package.json b/package.json index 77e6a63..f5b8770 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crapp", - "version": "1.2.2", + "version": "1.2.3", "private": true, "sideEffects": false, "type": "module", diff --git a/src/components/crapp/AvvisoCertificati.tsx b/src/components/crapp/AvvisoCertificati.tsx index d0a68ad..e9c1be4 100644 --- a/src/components/crapp/AvvisoCertificati.tsx +++ b/src/components/crapp/AvvisoCertificati.tsx @@ -18,7 +18,7 @@ function Card({ righe, cliccabile, }: { - colore: "warning" | "primary"; + colore: "warning" | "primary" | "destructive"; titolo: string; righe: string[]; cliccabile?: boolean; @@ -26,7 +26,9 @@ function Card({ const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${ colore === "warning" ? "bg-warning text-warning-foreground" - : "bg-primary text-primary-foreground" + : colore === "destructive" + ? "bg-destructive text-destructive-foreground" + : "bg-primary text-primary-foreground" } ${cliccabile ? "premi" : ""}`; const contenuto = ( <> @@ -50,7 +52,7 @@ function Card({ } /** - * Avviso certificati in scadenza/scaduti (DD-035, DD-041): calcolato al volo, niente push. + * Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push. * Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7. */ export function AvvisoCertificati() { @@ -65,8 +67,18 @@ export function AvvisoCertificati() { const oggi = oggiISO(); // Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041). - const { personale, staff } = avvisiCertificatiUtente(rosa, profili, oggi, { admin, base }); - const cardPersonale = personale ? ( + const { personale, personaleMancante, staff } = avvisiCertificatiUtente(rosa, profili, oggi, { + admin, + base, + }); + const cardPersonale = personaleMancante ? ( + + ) : personale ? ( personale.giorni < 0 ? ( {cardPersonale} + {mancanti.length > 0 ? ( + `${m.nome} ${m.cognome} — non caricato`)} + /> + ) : null} {scaduti.length > 0 ? ( ; + function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number { return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome); } /** * Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da - * `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha - * ancora un certificato caricato (è un problema diverso, non un avviso di scadenza). + * `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa. Chi non ha + * ancora un certificato caricato non ha una scadenza: finisce in `mancanti` (DD-046). * `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041). */ export function avvisiCertificati( @@ -262,13 +265,21 @@ export function avvisiCertificati( profili: Record, oggi: string, soglia: number = GIORNI_AVVISO_CERTIFICATO, -): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } { +): { + scaduti: AvvisoCertificato[]; + inScadenza: AvvisoCertificato[]; + mancanti: CertificatoMancante[]; +} { const scaduti: AvvisoCertificato[] = []; const inScadenza: AvvisoCertificato[] = []; + const mancanti: CertificatoMancante[] = []; for (const g of rosa) { if (!inRosa(g)) continue; const p = profili[g.id]; - if (!p?.certificatoScadenza || !p.certificatoPath) continue; + if (!p?.certificatoScadenza || !p.certificatoPath) { + mancanti.push({ giocatoreId: g.id, nome: g.nome, cognome: g.cognome }); + continue; + } const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi); if (giorni > soglia) continue; const avviso: AvvisoCertificato = { @@ -282,14 +293,15 @@ export function avvisiCertificati( } scaduti.sort(confrontaAvvisi); inScadenza.sort(confrontaAvvisi); - return { scaduti, inScadenza }; + mancanti.sort((a, b) => a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome)); + return { scaduti, inScadenza, mancanti }; } /** * Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio - * certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa; `staff` sono gli - * altri della rosa (soglia 7 giorni), solo per l'admin, senza il suo nome perché già nel - * personale. + * certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa, e `personaleMancante` + * dice che non l'ha ancora caricato (DD-046); `staff` sono gli altri della rosa (soglia 7 + * giorni), solo per l'admin, senza il suo nome perché già nel personale. */ export function avvisiCertificatiUtente( rosa: GiocatoreSquadra[], @@ -298,7 +310,8 @@ export function avvisiCertificatiUtente( utente: { admin: boolean; base: GiocatoreSquadra | null }, ): { personale: AvvisoCertificato | undefined; - staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null; + personaleMancante: boolean; + staff: ReturnType | null; } { const mio = utente.base ? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE) @@ -311,5 +324,5 @@ export function avvisiCertificatiUtente( oggi, ) : null; - return { personale, staff }; + return { personale, personaleMancante: !!mio && mio.mancanti.length > 0, staff }; } diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts index 1d2eda8..6c25d03 100644 --- a/test/unit/profili-core.test.ts +++ b/test/unit/profili-core.test.ts @@ -262,15 +262,31 @@ assert.deepEqual( ["g1"], ); -assert.equal( - avvisiCertificati( - [{ ...squadra[0]!, id: "g6" }], - { g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } }, - oggiTest, - ).inScadenza.length, - 0, - "certificato mancante: nessun avviso", +const senzaCertificato = avvisiCertificati( + [{ ...squadra[0]!, id: "g6" }], + { g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } }, + oggiTest, ); +assert.equal(senzaCertificato.inScadenza.length + senzaCertificato.scaduti.length, 0); +assert.deepEqual( + senzaCertificato.mancanti.map((m) => m.giocatoreId), + ["g6"], + "certificato mancante (DD-046): in `mancanti`, non tra scadenze", +); +// DD-046: profilo assente e scadenza senza file valgono come mancante; fuori rosa e allenatori no +const mancantiRosa = avvisiCertificati( + [ + ...rosaCertificati, + { ...squadra[0]!, id: "g7", nome: "Zeno", cognome: "Zeta", attivo: true, tipo: "giocatore" }, + { ...squadra[0]!, id: "g8", nome: "Aldo", cognome: "Alfa", attivo: true, tipo: "giocatore" }, + ], + { + ...profiliCertificati, + g8: { ...vuoto, giocatoreId: "g8", certificatoScadenza: "2026-12-01", certificatoPath: null }, + }, + oggiTest, +).mancanti.map((m) => m.giocatoreId); +assert.deepEqual(mancantiRosa, ["g8", "g7"], "g7 senza profilo, g8 senza file; ordine per cognome"); const dueInScadenza: Record = { g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano @@ -412,5 +428,24 @@ const soloGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTes }); assert.equal(soloGiocatore.personale?.giocatoreId, "g1"); assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff"); +assert.equal(soloGiocatore.personaleMancante, false); + +// DD-046: chi non ha caricato il certificato lo vede nel personale; l'admin lo vede anche per gli altri +const adminSenzaFile = avvisiCertificatiUtente(rosaUtente, {}, oggiTest, { + admin: true, + base: rosaUtente[0]!, +}); +assert.equal(adminSenzaFile.personaleMancante, true); +assert.equal(adminSenzaFile.personale, undefined); +assert.deepEqual( + adminSenzaFile.staff?.mancanti.map((m) => m.giocatoreId), + ["g2"], + "lo staff non ripete il suo nome tra i mancanti", +); +assert.equal( + avvisiCertificatiUtente(rosaUtente, {}, oggiTest, { admin: true, base: null }).personaleMancante, + false, + "admin non giocatore: nessun avviso personale", +); console.log("profili-core: ok");