diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index 1e95fbb..c39731a 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -178,10 +178,11 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
## Prossimo sviluppo
-**Avviso certificati in scadenza** (24/09/2026, branch `develop`): specifica scritta in
-[docs/modules/profilo-giocatore.md](docs/modules/profilo-giocatore.md#avviso-certificati),
-decisione in DD-035, codice non ancora iniziato. Solo client: nessuna migration e nessuna
-push. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
+**Avviso certificati in scadenza** (27/09/2026, branch `develop`): codice scritto secondo la
+specifica in [docs/modules/profilo-giocatore.md](docs/modules/profilo-giocatore.md#avviso-certificati)
+(decisione DD-035), non ancora rilasciato in una versione. Solo client: nessuna migration e
+nessuna push. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
+«Prossimo».
---
diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md
index f2ff735..785fc6f 100644
--- a/docs/modules/profilo-giocatore.md
+++ b/docs/modules/profilo-giocatore.md
@@ -82,7 +82,7 @@ sulla tab Stagione.
### Avviso certificati
-**Stato:** da implementare (DD-035).
+**Stato:** implementato (DD-035).
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
diff --git a/src/components/crapp/AvvisoCertificati.tsx b/src/components/crapp/AvvisoCertificati.tsx
new file mode 100644
index 0000000..94b4ac9
--- /dev/null
+++ b/src/components/crapp/AvvisoCertificati.tsx
@@ -0,0 +1,113 @@
+import { AlertCircle } from "lucide-react";
+import { Link } from "@tanstack/react-router";
+import { useProfili } from "@/lib/profili";
+import {
+ avvisiCertificati,
+ formatDataBreve,
+ testoScadenza,
+ type AvvisoCertificato,
+} from "@/lib/profili-core";
+import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
+import { oggiISO } from "@/lib/palloni-core";
+import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
+import { useGiocatoreBase } from "@/lib/user-store";
+
+function Card({
+ colore,
+ titolo,
+ righe,
+ cliccabile,
+}: {
+ colore: "warning" | "primary";
+ titolo: string;
+ righe: string[];
+ cliccabile?: boolean;
+}) {
+ 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"
+ } ${cliccabile ? "premi" : ""}`;
+ const contenuto = (
+ <>
+
+ {titolo}
+
+ {righe.map((r) => (
+
+ {r}
+
+ ))}
+ >
+ );
+ return cliccabile ? (
+
+ {contenuto}
+
+ ) : (
+ {contenuto}
+ );
+}
+
+/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
+export function AvvisoCertificati() {
+ const admin = useIsAdmin();
+ const ruoliPronti = useRuoliPronti();
+ const base = useGiocatoreBase();
+ const { righe: rosa } = useGiocatoriSquadra();
+ const { profili, isPending } = useProfili();
+
+ if (!ruoliPronti || isPending) return null;
+
+ 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}
+ >
+ );
+ }
+
+ 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 ? (
+
+ ) : (
+
+ );
+}
+
+function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
+ return `${a.nome} ${a.cognome} — ${dettaglio}`;
+}
diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts
index 20d8e86..aff48d6 100644
--- a/src/lib/profili-core.ts
+++ b/src/lib/profili-core.ts
@@ -1,5 +1,5 @@
import { rigaCsv } from "./scout-export";
-import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
+import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
/**
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
@@ -215,3 +215,73 @@ export function csvTesseramento(
export function etichettaGiocatore(g: GiocatoreSquadra): string {
return `#${g.numero} ${nomeCompleto(g)}`;
}
+
+/** Soglia dell'avviso certificati in Home (DD-035). */
+export const GIORNI_AVVISO_CERTIFICATO = 7;
+
+/** 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`);
+ const b = new Date(`${oggi}T00:00:00`);
+ return Math.round((a.getTime() - b.getTime()) / 86_400_000);
+}
+
+/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
+export function formatDataBreve(iso: string): string {
+ return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ });
+}
+
+/** Riga dell'avviso certificati per un giocatore (DD-035). */
+export function testoScadenza(giorni: number): string {
+ if (giorni === 0) return "scade oggi";
+ if (giorni === 1) return "scade domani";
+ return `scade tra ${giorni} giorni`;
+}
+
+export type AvvisoCertificato = {
+ giocatoreId: string;
+ nome: string;
+ cognome: string;
+ scadenza: string;
+ giorni: number;
+};
+
+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).
+ */
+export function avvisiCertificati(
+ rosa: GiocatoreSquadra[],
+ profili: Record,
+ oggi: string,
+): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
+ const scaduti: AvvisoCertificato[] = [];
+ const inScadenza: AvvisoCertificato[] = [];
+ for (const g of rosa) {
+ if (!inRosa(g)) continue;
+ const p = profili[g.id];
+ if (!p?.certificatoScadenza || !p.certificatoPath) continue;
+ const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
+ if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
+ const avviso: AvvisoCertificato = {
+ giocatoreId: g.id,
+ nome: g.nome,
+ cognome: g.cognome,
+ scadenza: p.certificatoScadenza,
+ giorni,
+ };
+ (giorni < 0 ? scaduti : inScadenza).push(avviso);
+ }
+ scaduti.sort(confrontaAvvisi);
+ inScadenza.sort(confrontaAvvisi);
+ return { scaduti, inScadenza };
+}
diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts
index 4ae1dbf..2398c6d 100644
--- a/src/lib/ruoli.ts
+++ b/src/lib/ruoli.ts
@@ -24,15 +24,23 @@ async function fetchRuoli(utenteId: string | null): Promise {
return (data ?? []).map((r) => r.role as Ruolo);
}
-function useRuoli(): Ruolo[] {
+function useRuoliQuery() {
const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
- const query = useQuery({
+ return useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoli(utenteId),
staleTime: 30 * 60_000,
});
- return query.data ?? [];
+}
+
+function useRuoli(): Ruolo[] {
+ return useRuoliQuery().data ?? [];
+}
+
+/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
+export function useRuoliPronti(): boolean {
+ return !useRuoliQuery().isPending;
}
export function useIsAdmin(): boolean {
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 46b1f07..2c72149 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
+import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { Reveal } from "@/components/motion/Reveal";
@@ -120,6 +121,8 @@ function Index() {
+
+
= {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
+ g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
+ g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
+ g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
+};
+const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
+assert.deepEqual(
+ avvisi.scaduti.map((a) => a.giocatoreId),
+ ["g2"],
+);
+assert.deepEqual(
+ avvisi.inScadenza.map((a) => a.giocatoreId),
+ ["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 dueInScadenza: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
+};
+const ordinati = avvisiCertificati(
+ [
+ { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
+ { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
+ ],
+ dueInScadenza,
+ oggiTest,
+).inScadenza.map((a) => a.giocatoreId);
+assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
+
+const pariGiorni: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
+};
+const alfabetico = avvisiCertificati(
+ [
+ { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
+ { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
+ ],
+ pariGiorni,
+ oggiTest,
+).inScadenza.map((a) => a.giocatoreId);
+assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
+
+// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
+const rosaSoglia: 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 profiliSoglia: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
+ g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
+};
+const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
+assert.deepEqual(
+ soglia.inScadenza.map((a) => a.giocatoreId),
+ ["g3", "g2"],
+ "8 giorni resta valido, 7 e 0 generano avviso",
+);
+assert.equal(soglia.scaduti.length, 0);
+
+// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
+const rosaScaduti: GiocatoreSquadra[] = [
+ { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
+ { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
+ { ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
+];
+const profiliScaduti: Record = {
+ g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
+ g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
+ g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
+};
+const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
+ (a) => a.giocatoreId,
+);
+assert.deepEqual(
+ scadutiOrdinati,
+ ["g2", "g1", "g3"],
+ "il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
+);
+
console.log("profili-core: ok");