diff --git a/src/lib/presenze-mese-core.ts b/src/lib/presenze-mese-core.ts new file mode 100644 index 0000000..18834de --- /dev/null +++ b/src/lib/presenze-mese-core.ts @@ -0,0 +1,72 @@ +import type { Evento } from "./eventi"; +import type { MappaPresenze } from "./presenze"; + +/** + * Calcolo puro della percentuale di presenze dell'ultimo mese, separato dagli hook + * (`presenze-mese.ts`) per poterlo testare: le tre regole che decidono il numero — + * finestra di 30 giorni, il ritardo che conta come presenza, `convocati` vuoto uguale + * a tutta la rosa — stanno qui. + */ + +export type StatistichePresenza = { presenti: number; totali: number; percentuale: number }; + +const NESSUNA: StatistichePresenza = { presenti: 0, totali: 0, percentuale: 0 }; + +/** Chi è arrivato in ritardo era comunque all'allenamento: conta come presente. */ +const eraPresente = (stato: string | undefined) => stato === "presente" || stato === "ritardo"; + +/** Finestra di 30 giorni che si chiude oggi, in date ISO. */ +export function finestraMese(oggi: Date = new Date()): { da: string; a: string } { + const inizio = new Date(oggi); + inizio.setDate(inizio.getDate() - 30); + return { da: inizio.toISOString().slice(0, 10), a: oggi.toISOString().slice(0, 10) }; +} + +/** Partite e allenamenti dentro la finestra: gli altri eventi non contano. */ +function eventiDelMese(eventi: Evento[], oggi: Date): Evento[] { + const { da, a } = finestraMese(oggi); + return eventi.filter( + (e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a, + ); +} + +function conPercentuale(presenti: number, totali: number): StatistichePresenza { + return { presenti, totali, percentuale: totali ? Math.round((presenti / totali) * 100) : 0 }; +} + +/** Presenze dell'ultimo mese di un singolo giocatore. */ +export function presenzeUltimoMese( + giocatoreId: string | undefined, + eventi: Evento[], + presenze: MappaPresenze, + oggi: Date = new Date(), +): StatistichePresenza { + if (!giocatoreId) return NESSUNA; + const rilevanti = eventiDelMese(eventi, oggi).filter( + (e) => e.convocati.length === 0 || e.convocati.includes(giocatoreId), + ); + const presenti = rilevanti.filter((e) => eraPresente(presenze[e.id]?.[giocatoreId])).length; + return conPercentuale(presenti, rilevanti.length); +} + +/** + * Presenze dell'ultimo mese di tutta la rosa, in una mappa per id. Un evento senza + * convocati vale per tutti gli attivi, uno con l'elenco solo per i convocati. + */ +export function presenzeUltimoMeseTutti( + eventi: Evento[], + presenze: MappaPresenze, + idRosa: string[], + oggi: Date = new Date(), +): Record { + const out: Record = {}; + for (const e of eventiDelMese(eventi, oggi)) { + for (const id of e.convocati.length > 0 ? e.convocati : idRosa) { + const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 }); + rec.totali += 1; + if (eraPresente(presenze[e.id]?.[id])) rec.presenti += 1; + } + } + for (const [id, rec] of Object.entries(out)) out[id] = conPercentuale(rec.presenti, rec.totali); + return out; +} diff --git a/src/lib/presenze-mese.ts b/src/lib/presenze-mese.ts index 4ce106d..5d8cfcb 100644 --- a/src/lib/presenze-mese.ts +++ b/src/lib/presenze-mese.ts @@ -2,77 +2,35 @@ import { useMemo } from "react"; import { useEventi } from "./eventi"; import { useRispostePresenze } from "./presenze"; import { useGiocatoriSquadra } from "./giocatori-squadra"; +import { + presenzeUltimoMese, + presenzeUltimoMeseTutti, + type StatistichePresenza, +} from "./presenze-mese-core"; /** * Percentuale di presenze dell'ultimo mese (30 giorni), utile per le convocazioni. - * Usa solo cache già in memoria: nessuna query aggiuntiva. + * Usa solo cache già in memoria: nessuna query aggiuntiva. Il calcolo sta in + * `presenze-mese-core.ts`, qui c'è solo il collegamento agli hook. */ -export function usePresenzeUltimoMese(giocatoreId: string | undefined) { +export function usePresenzeUltimoMese(giocatoreId: string | undefined): StatistichePresenza { const { eventi } = useEventi(); const { presenze } = useRispostePresenze(); - return useMemo(() => { - if (!giocatoreId) return { presenti: 0, totali: 0, percentuale: 0 }; - const oggi = new Date(); - const inizio = new Date(oggi); - inizio.setDate(inizio.getDate() - 30); - const da = inizio.toISOString().slice(0, 10); - const a = oggi.toISOString().slice(0, 10); - - const rilevanti = eventi.filter( - (e) => - (e.tipo === "partita" || e.tipo === "allenamento") && - e.data >= da && - e.data <= a && - (e.convocati.length === 0 || e.convocati.includes(giocatoreId)), - ); - const presenti = rilevanti.filter((e) => { - const stato = presenze[e.id]?.[giocatoreId]; - return stato === "presente" || stato === "ritardo"; - }).length; - const totali = rilevanti.length; - return { - presenti, - totali, - percentuale: totali ? Math.round((presenti / totali) * 100) : 0, - }; - }, [eventi, presenze, giocatoreId]); + return useMemo( + () => presenzeUltimoMese(giocatoreId, eventi, presenze), + [eventi, presenze, giocatoreId], + ); } /** Percentuale presenze ultimi 30 giorni per tutti i giocatori (mappa per id). */ -export function usePresenzeUltimoMeseTutti(): Record< - string, - { presenti: number; totali: number; percentuale: number } -> { +export function usePresenzeUltimoMeseTutti(): Record { const { eventi } = useEventi(); const { presenze } = useRispostePresenze(); const { righe: squadra } = useGiocatoriSquadra(); return useMemo(() => { - const oggi = new Date(); - const inizio = new Date(oggi); - inizio.setDate(inizio.getDate() - 30); - const da = inizio.toISOString().slice(0, 10); - const a = oggi.toISOString().slice(0, 10); const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id); - - const rilevanti = eventi.filter( - (e) => (e.tipo === "partita" || e.tipo === "allenamento") && e.data >= da && e.data <= a, - ); - - const out: Record = {}; - for (const e of rilevanti) { - const ids = e.convocati.length > 0 ? e.convocati : idRosa; - for (const id of ids) { - const rec = (out[id] ??= { presenti: 0, totali: 0, percentuale: 0 }); - rec.totali += 1; - const stato = presenze[e.id]?.[id]; - if (stato === "presente" || stato === "ritardo") rec.presenti += 1; - } - } - for (const rec of Object.values(out)) { - rec.percentuale = rec.totali ? Math.round((rec.presenti / rec.totali) * 100) : 0; - } - return out; + return presenzeUltimoMeseTutti(eventi, presenze, idRosa); }, [eventi, presenze, squadra]); } diff --git a/test/unit/presenze-mese.test.ts b/test/unit/presenze-mese.test.ts new file mode 100644 index 0000000..972c137 --- /dev/null +++ b/test/unit/presenze-mese.test.ts @@ -0,0 +1,103 @@ +/** Check delle presenze dell'ultimo mese: `bun test/unit/presenze-mese.test.ts`. */ +import assert from "node:assert/strict"; +import type { Evento } from "@/lib/eventi"; +import type { MappaPresenze } from "@/lib/presenze"; +import { + finestraMese, + presenzeUltimoMese, + presenzeUltimoMeseTutti, +} from "@/lib/presenze-mese-core"; + +const ev = (id: string, tipo: Evento["tipo"], data: string, convocati: string[] = []): Evento => ({ + id, + tipo, + titolo: id, + luogo: "", + data, + ora: "20:00", + note: "", + convocati, + campionato: false, + casa: true, + pagelleChiuse: false, +}); + +const OGGI = new Date("2026-09-04T12:00:00Z"); + +// --- la finestra ------------------------------------------------------------------ +const { da, a } = finestraMese(OGGI); +assert.equal(a, "2026-09-04", "la finestra si chiude oggi"); +assert.equal(da, "2026-08-05", "e si apre 30 giorni prima"); + +// --- un singolo giocatore ---------------------------------------------------------- +const eventi: Evento[] = [ + ev("a1", "allenamento", "2026-08-06"), // dentro + ev("a2", "allenamento", "2026-08-20"), // dentro, ritardo + ev("p1", "partita", "2026-09-01"), // dentro, assente + ev("a0", "allenamento", "2026-07-30"), // fuori: più vecchio di 30 giorni + ev("c1", "compleanno", "2026-08-15"), // dentro, ma non è né partita né allenamento + ev("a3", "allenamento", "2026-08-25", ["g2"]), // dentro, ma g1 non è convocato +]; + +const presenze: MappaPresenze = { + a1: { g1: "presente", g2: "presente" }, + a2: { g1: "ritardo", g2: "assente" }, + p1: { g1: "assente", g2: "presente" }, + a0: { g1: "presente" }, + c1: { g1: "presente" }, + a3: { g2: "presente" }, +}; + +const g1 = presenzeUltimoMese("g1", eventi, presenze, OGGI); +assert.deepEqual( + g1, + { presenti: 2, totali: 3, percentuale: 67 }, + "3 eventi rilevanti su 6: il ritardo conta come presenza, la percentuale è arrotondata", +); + +assert.equal( + presenzeUltimoMese("g1", [ev("a0", "allenamento", "2026-07-30")], presenze, OGGI).percentuale, + 0, + "senza eventi nella finestra la percentuale è 0, non NaN", +); + +assert.deepEqual( + presenzeUltimoMese(undefined, eventi, presenze, OGGI), + { presenti: 0, totali: 0, percentuale: 0 }, + "senza giocatore selezionato non si calcola niente", +); + +assert.equal( + presenzeUltimoMese("g9", eventi, presenze, OGGI).totali, + 3, + "chi non ha mai risposto ha comunque il denominatore degli eventi aperti a tutti", +); + +// --- tutta la rosa ------------------------------------------------------------------ +const tutti = presenzeUltimoMeseTutti(eventi, presenze, ["g1", "g2"], OGGI); + +assert.deepEqual(tutti["g1"], g1, "la mappa e il calcolo singolo dicono la stessa cosa su g1"); +assert.deepEqual( + tutti["g2"], + { presenti: 3, totali: 4, percentuale: 75 }, + "g2 ha anche l'allenamento in cui era l'unico convocato", +); + +// Il caso che distingue le due regole: un evento con l'elenco dei convocati non +// entra nel denominatore di chi non è stato chiamato. +const soloConvocati = presenzeUltimoMeseTutti( + [ev("a3", "allenamento", "2026-08-25", ["g2"])], + presenze, + ["g1", "g2"], + OGGI, +); +assert.equal(soloConvocati["g1"], undefined, "chi non è convocato non compare nella mappa"); +assert.equal(soloConvocati["g2"]?.totali, 1, "il convocato sì"); + +assert.deepEqual( + presenzeUltimoMeseTutti([], presenze, ["g1"], OGGI), + {}, + "senza eventi la mappa è vuota", +); + +console.log("presenze mese: ok");