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");