diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index e74a0e4..d7b7053 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -13,6 +13,12 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
+### Modificato
+
+- **Avviso certificati: giocatore a 30 giorni** — il giocatore vede l'avviso giallo sul proprio
+ certificato medico da un mese prima della scadenza (prima 7 giorni). Per gli admin non cambia
+ nulla: 7 giorni prima in giallo, nero dopo la scadenza (DD-041).
+
## [1.2.1] - 2026-09-30
Turno palloni e sollecito presenze partono anche da soli, a orari fissi prima dell'evento, e i testi di
diff --git a/docs/decisions/DD-035.md b/docs/decisions/DD-035.md
index 31c474c..64875cd 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)
+**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.
**Contesto**
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
@@ -49,7 +49,7 @@ certificato viene rinnovato. Specifica completa in
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
da solo.
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
-- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
+- ~~Un admin che è anche giocatore vede solo l'avviso dello staff~~ → superato da DD-041: vede anche il proprio avviso personale.
**Riesame**
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
diff --git a/docs/decisions/DD-041.md b/docs/decisions/DD-041.md
new file mode 100644
index 0000000..688ea4f
--- /dev/null
+++ b/docs/decisions/DD-041.md
@@ -0,0 +1,39 @@
+# DD-041 — Avviso certificati: il giocatore è avvisato un mese prima dello staff
+
+**Data:** 1 ottobre 2026
+**Stato:** Accettata (modifica la soglia di [DD-035](DD-035.md) per il solo giocatore)
+
+**Contesto**
+Con DD-035 giocatore e admin vedono l'avviso negli stessi 7 giorni prima della scadenza. Per
+il giocatore sono pochi: prenotare una visita di idoneità richiede più tempo (era il rischio
+indicato nel Riesame di DD-035). All'admin invece un avviso così anticipato riempirebbe la
+Home di nomi che non può ancora sollecitare con urgenza.
+
+**Decisione**
+
+1. Il **giocatore** vede l'avviso giallo da **30 giorni** prima della scadenza
+ (`GIORNI_AVVISO_CERTIFICATO_GIOCATORE`).
+2. L'**admin** resta a **7 giorni** prima (`GIORNI_AVVISO_CERTIFICATO`), invariato.
+3. Dopo la scadenza l'avviso è **nero** per entrambi, invariato.
+4. L'**admin che è anche giocatore** vede il proprio avviso **personale** (30 giorni,
+ cliccabile) oltre a quello dello staff, dal quale il suo nome è tolto per non duplicarlo.
+5. Nessun altro aspetto di DD-035 cambia: calcolo al volo, niente push, stessi destinatari.
+
+Esempio: scadenza il 5 ottobre 2026 → dal 5 settembre l'avviso giallo compare solo al
+giocatore; dal 28 settembre compare anche agli admin; dal 6 ottobre è nero per entrambi.
+
+**Alternative scartate**
+
+- «Un mese» come mese di calendario → la soglia varierebbe tra 28 e 31 giorni; 30 giorni fissi
+ sono prevedibili e coincidono con l'esempio della richiesta.
+- Soglia unica a 30 giorni anche per l'admin → elenco dello staff pieno con settimane di
+ anticipo, senza nulla da fare per l'admin che non può caricare il file (DD-017).
+
+**Conseguenze**
+
+- Il giocatore vede il conto alla rovescia «scade tra N giorni» con N fino a 30.
+- `avvisiCertificati()` riceve la soglia come parametro (default 7, cioè lo staff).
+
+**Riesame**
+Se 30 giorni risultano ancora pochi o troppi, cambiare la costante; se serve una soglia
+regolabile dall'admin, vedi il Riesame di DD-035.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index db9f954..de708db 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -46,46 +46,47 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate**
-| ID | Titolo |
-| ------------------- | ------------------------------------- |
-| [DD-001](DD-001.md) | Indipendenza da Lovable |
-| [DD-002](DD-002.md) | Sviluppo document-first |
-| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
-| [DD-005](DD-005.md) | Mobile-first |
-| [DD-006](DD-006.md) | AI solo se utile |
-| [DD-007](DD-007.md) | Badge calcolati, non in DB |
-| [DD-008](DD-008.md) | Gamification equa tra ruoli |
-| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
-| [DD-010](DD-010.md) | Niente storico certificati |
-| [DD-011](DD-011.md) | Auth reale prima del profilo |
-| [DD-012](DD-012.md) | Rimandare la migrazione ID |
-| [DD-013](DD-013.md) | Portabilità dello stack |
-| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
-| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
-| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
-| [DD-018](DD-018.md) | Collegamento automatico per email |
-| [DD-019](DD-019.md) | Il branch lo decide l'utente |
-| [DD-020](DD-020.md) | Test obbligatori e verdi |
-| [DD-021](DD-021.md) | Molle interrompibili con motion |
-| [DD-022](DD-022.md) | App solo chiara |
-| [DD-023](DD-023.md) | Scritture limitate per ruolo |
-| [DD-024](DD-024.md) | Route di notifica autenticate |
-| [DD-025](DD-025.md) | Promemoria palloni manuale |
-| [DD-026](DD-026.md) | Payload push cifrato |
-| [DD-027](DD-027.md) | Voto limitato ai convocati |
-| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
-| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
-| [DD-030](DD-030.md) | Centro notifiche in-app |
-| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
-| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
-| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
-| [DD-034](DD-034.md) | Ruolo allenatore |
-| [DD-035](DD-035.md) | Avviso certificati in Home |
-| [DD-036](DD-036.md) | Notifiche email via Gmail |
-| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
-| [DD-038](DD-038.md) | Tre canali per ogni notifica |
-| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
-| [DD-040](DD-040.md) | Palloni e solleciti automatici |
+| ID | Titolo |
+| ------------------- | ----------------------------------------- |
+| [DD-001](DD-001.md) | Indipendenza da Lovable |
+| [DD-002](DD-002.md) | Sviluppo document-first |
+| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
+| [DD-005](DD-005.md) | Mobile-first |
+| [DD-006](DD-006.md) | AI solo se utile |
+| [DD-007](DD-007.md) | Badge calcolati, non in DB |
+| [DD-008](DD-008.md) | Gamification equa tra ruoli |
+| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
+| [DD-010](DD-010.md) | Niente storico certificati |
+| [DD-011](DD-011.md) | Auth reale prima del profilo |
+| [DD-012](DD-012.md) | Rimandare la migrazione ID |
+| [DD-013](DD-013.md) | Portabilità dello stack |
+| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
+| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
+| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
+| [DD-018](DD-018.md) | Collegamento automatico per email |
+| [DD-019](DD-019.md) | Il branch lo decide l'utente |
+| [DD-020](DD-020.md) | Test obbligatori e verdi |
+| [DD-021](DD-021.md) | Molle interrompibili con motion |
+| [DD-022](DD-022.md) | App solo chiara |
+| [DD-023](DD-023.md) | Scritture limitate per ruolo |
+| [DD-024](DD-024.md) | Route di notifica autenticate |
+| [DD-025](DD-025.md) | Promemoria palloni manuale |
+| [DD-026](DD-026.md) | Payload push cifrato |
+| [DD-027](DD-027.md) | Voto limitato ai convocati |
+| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
+| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
+| [DD-030](DD-030.md) | Centro notifiche in-app |
+| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
+| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
+| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
+| [DD-034](DD-034.md) | Ruolo allenatore |
+| [DD-035](DD-035.md) | Avviso certificati in Home |
+| [DD-036](DD-036.md) | Notifiche email via Gmail |
+| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
+| [DD-038](DD-038.md) | Tre canali per ogni notifica |
+| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
+| [DD-040](DD-040.md) | Palloni e solleciti automatici |
+| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
**In valutazione**
diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md
index 785fc6f..0e00435 100644
--- a/docs/modules/profilo-giocatore.md
+++ b/docs/modules/profilo-giocatore.md
@@ -82,7 +82,7 @@ sulla tab Stagione.
### Avviso certificati
-**Stato:** implementato (DD-035).
+**Stato:** implementato (DD-035, soglia del giocatore DD-041).
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
@@ -90,13 +90,13 @@ già salvata: niente tabella, niente migration, niente push.
#### Chi lo vede
-| Utente | Cosa vede | Al tocco |
-| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
-| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
-| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
-| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
-| Altri giocatori | niente | — |
-| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
+| Utente | Cosa vede | Al tocco |
+| --------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------- |
+| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
+| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
+| Admin che è anche giocatore | **entrambi**: lo staff (senza il suo nome) e il proprio personale, a 30 giorni | personale: apre `/profilo?tab=documenti` |
+| Altri giocatori | niente | — |
+| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
@@ -109,15 +109,24 @@ solo proprio a un giocatore.
#### Quando compare
-`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
-`GIORNI_AVVISO_CERTIFICATO = 7`.
+`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale). La
+soglia del giallo dipende da chi guarda (DD-041): il giocatore è avvisato un mese prima dello
+staff.
-| Condizione | Stato | Avviso |
-| -------------------- | ----------- | ------------------ |
-| `giorni > 7` | valido | nessuno |
-| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
-| `giorni < 0` | scaduto | nero (`primary`) |
-| data o file mancanti | mancante | nessuno |
+| Chi guarda | Soglia |
+| ---------- | ------------------------------------------ |
+| 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 |
+
+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.
- 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.
@@ -136,7 +145,7 @@ solo proprio a un giocatore.
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
-Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
+Il numero di giorni scende da solo ogni giorno (30… 7, 6, 5… domani, oggi): l'avviso non
conserva niente, rilegge la data a ogni apertura della Home.
#### Più giocatori
@@ -182,14 +191,16 @@ la Home non deve rompersi per un dato accessorio.
#### Implementazione
-| Pezzo | Ruolo |
-| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
-| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
-| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
-| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
-| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
-| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
-| `src/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 }`, 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 `` |
- "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.
@@ -197,7 +208,7 @@ la Home non deve rompersi per un dato accessorio.
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
i profili.
-- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
+- Test in `test/unit/profili-core.test.ts`: avvisi per utente (admin giocatore, admin puro, giocatore semplice), soglia dello staff (8 giorni no, 7 sì, 0 sì, −1 scaduto) e del giocatore (31 no, 30 sì),
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
di anno nel calcolo dei giorni.
@@ -254,8 +265,8 @@ Upload.
Il giocatore può aggiornare liberamente sia la data sia il file.
-Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
-riceve un avviso nella propria Home e compare in quello degli admin (vedi
+Quando mancano 30 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
+riceve un avviso nella propria Home; compare in quello degli admin da 7 giorni prima (vedi
[Avviso certificati](#avviso-certificati)).
Lo storico non viene mantenuto nella prima versione.
diff --git a/src/components/crapp/AvvisoCertificati.tsx b/src/components/crapp/AvvisoCertificati.tsx
index 94b4ac9..d0a68ad 100644
--- a/src/components/crapp/AvvisoCertificati.tsx
+++ b/src/components/crapp/AvvisoCertificati.tsx
@@ -2,7 +2,7 @@ import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useProfili } from "@/lib/profili";
import {
- avvisiCertificati,
+ avvisiCertificatiUtente,
formatDataBreve,
testoScadenza,
type AvvisoCertificato,
@@ -49,7 +49,10 @@ function Card({
);
}
-/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
+/**
+ * Avviso certificati in scadenza/scaduti (DD-035, DD-041): calcolato al volo, niente push.
+ * Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
+ */
export function AvvisoCertificati() {
const admin = useIsAdmin();
const ruoliPronti = useRuoliPronti();
@@ -61,50 +64,48 @@ export function AvvisoCertificati() {
const oggi = oggiISO();
- if (admin) {
- const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
- if (scaduti.length === 0 && inScadenza.length === 0) return null;
- return (
- <>
- {scaduti.length > 0 ? (
- rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
- />
- ) : null}
- {inScadenza.length > 0 ? (
- rigaStaff(a, testoScadenza(a.giorni)))}
- />
- ) : null}
- >
- );
- }
+ // 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 ? (
+ personale.giorni < 0 ? (
+
+ ) : (
+
+ )
+ ) : null;
- if (!base) return null;
- const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
- const mio = scaduti[0] ?? inScadenza[0];
- if (!mio) return null;
-
- return mio.giorni < 0 ? (
-
- ) : (
-
+ if (!staff) return cardPersonale;
+ const { scaduti, inScadenza } = staff;
+ return (
+ <>
+ {cardPersonale}
+ {scaduti.length > 0 ? (
+ rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
+ />
+ ) : null}
+ {inScadenza.length > 0 ? (
+ rigaStaff(a, testoScadenza(a.giorni)))}
+ />
+ ) : null}
+ >
);
}
diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts
index aff48d6..0841b58 100644
--- a/src/lib/profili-core.ts
+++ b/src/lib/profili-core.ts
@@ -216,9 +216,12 @@ export function etichettaGiocatore(g: GiocatoreSquadra): string {
return `#${g.numero} ${nomeCompleto(g)}`;
}
-/** Soglia dell'avviso certificati in Home (DD-035). */
+/** Soglia dell'avviso certificati dello staff in Home (DD-035). */
export const GIORNI_AVVISO_CERTIFICATO = 7;
+/** Soglia dell'avviso personale: il giocatore è avvisato un mese prima dello staff (DD-041). */
+export const GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30;
+
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
const a = new Date(`${scadenza}T00:00:00`);
@@ -258,11 +261,13 @@ function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
* 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).
+ * `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
*/
export function avvisiCertificati(
rosa: GiocatoreSquadra[],
profili: Record,
oggi: string,
+ soglia: number = GIORNI_AVVISO_CERTIFICATO,
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
const scaduti: AvvisoCertificato[] = [];
const inScadenza: AvvisoCertificato[] = [];
@@ -271,7 +276,7 @@ export function avvisiCertificati(
const p = profili[g.id];
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
- if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
+ if (giorni > soglia) continue;
const avviso: AvvisoCertificato = {
giocatoreId: g.id,
nome: g.nome,
@@ -285,3 +290,32 @@ export function avvisiCertificati(
inScadenza.sort(confrontaAvvisi);
return { scaduti, inScadenza };
}
+
+/**
+ * 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.
+ */
+export function avvisiCertificatiUtente(
+ rosa: GiocatoreSquadra[],
+ profili: Record,
+ oggi: string,
+ utente: { admin: boolean; base: GiocatoreSquadra | null },
+): {
+ personale: AvvisoCertificato | undefined;
+ staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null;
+} {
+ const mio = utente.base
+ ? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
+ : null;
+ const personale = mio ? (mio.scaduti[0] ?? mio.inScadenza[0]) : undefined;
+ const staff = utente.admin
+ ? avvisiCertificati(
+ rosa.filter((g) => g.id !== utente.base?.id),
+ profili,
+ oggi,
+ )
+ : null;
+ return { personale, staff };
+}
diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts
index aa9f2a8..7657399 100644
--- a/test/unit/profili-core.test.ts
+++ b/test/unit/profili-core.test.ts
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import {
aRigaProfilo,
avvisiCertificati,
+ avvisiCertificatiUtente,
+ GIORNI_AVVISO_CERTIFICATO_GIOCATORE,
completamento,
completamentoAllenatore,
csvTesseramento,
@@ -333,4 +335,78 @@ assert.deepEqual(
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
);
+// soglia del giocatore (DD-041): 30 giorni di preavviso invece di 7
+const rosaMese: GiocatoreSquadra[] = [
+ { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
+ { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
+ { ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
+];
+const profiliMese: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-10-21", certificatoPath: "p1" }, // 31 giorni: fuori
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-10-20", certificatoPath: "p2" }, // 30 giorni: dentro
+ g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-10", certificatoPath: "p3" }, // scaduto
+};
+assert.equal(GIORNI_AVVISO_CERTIFICATO_GIOCATORE, 30);
+const mese = avvisiCertificati(
+ rosaMese,
+ profiliMese,
+ oggiTest,
+ GIORNI_AVVISO_CERTIFICATO_GIOCATORE,
+);
+assert.deepEqual(
+ mese.inScadenza.map((a) => a.giocatoreId),
+ ["g2"],
+ "31 giorni resta valido, 30 genera l'avviso del giocatore",
+);
+assert.deepEqual(
+ mese.scaduti.map((a) => a.giocatoreId),
+ ["g3"],
+);
+const staffMese = avvisiCertificati(rosaMese, profiliMese, oggiTest);
+assert.equal(staffMese.inScadenza.length, 0, "senza soglia esplicita resta 7 giorni (staff)");
+assert.deepEqual(
+ staffMese.scaduti.map((a) => a.giocatoreId),
+ ["g3"],
+);
+
+// avvisi per utente (DD-041): admin giocatore, admin puro, giocatore semplice
+const rosaUtente: GiocatoreSquadra[] = [
+ { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
+ { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
+];
+const profiliUtente: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-10-10", certificatoPath: "p1" }, // 20 giorni
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" }, // 5 giorni
+};
+const adminGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
+ admin: true,
+ base: rosaUtente[0]!,
+});
+assert.equal(
+ adminGiocatore.personale?.giocatoreId,
+ "g1",
+ "admin giocatore: avviso personale a 30 giorni",
+);
+assert.deepEqual(
+ adminGiocatore.staff?.inScadenza.map((a) => a.giocatoreId),
+ ["g2"],
+ "lo staff non ripete il suo nome",
+);
+const adminPuro = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
+ admin: true,
+ base: null,
+});
+assert.equal(adminPuro.personale, undefined);
+assert.deepEqual(
+ adminPuro.staff?.inScadenza.map((a) => a.giocatoreId),
+ ["g2"],
+ "admin non giocatore: solo staff a 7 giorni (g1 a 20 giorni escluso)",
+);
+const soloGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTest, {
+ admin: false,
+ base: rosaUtente[0]!,
+});
+assert.equal(soloGiocatore.personale?.giocatoreId, "g1");
+assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff");
+
console.log("profili-core: ok");