Separa il calcolo delle presenze del mese dagli hook e lo copre con i test.
La percentuale di presenze degli ultimi 30 giorni decide le convocazioni, ma stava dentro due useMemo — scritta due volte, una per giocatore e una per la rosa — e nessun test la toccava. Le tre regole che la determinano non sono ovvie: la finestra è di 30 giorni, il ritardo conta come presenza, e convocati vuoto significa tutta la rosa attiva. presenze-mese-core.ts contiene ora il calcolo puro, presenze-mese.ts solo il collegamento agli hook. Comportamento invariato, inclusa la differenza fra i due percorsi: per giocatore si filtrano i convocati, per la rosa l'elenco vuoto si espande agli attivi. Il test fissa le tre regole e i casi che le rompono: nessun evento nella finestra dà 0 e non NaN, chi non è convocato resta fuori dal denominatore, chi non ha mai risposto ci resta dentro. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, StatistichePresenza> {
|
||||
const out: Record<string, StatistichePresenza> = {};
|
||||
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;
|
||||
}
|
||||
+14
-56
@@ -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<string, StatistichePresenza> {
|
||||
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<string, { presenti: number; totali: number; percentuale: number }> = {};
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user