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:
2026-09-06 11:41:14 +02:00
co-authored by Claude Opus 5
parent 94673090f1
commit 0addafa76f
3 changed files with 189 additions and 56 deletions
+72
View File
@@ -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
View File
@@ -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]);
}