Obiettivi cliccabili con card di dettaglio; presenze del mese sui convocati

Ogni obiettivo di squadra apre un drawer che spiega come si calcola, cosa conta,
cosa è una presenza persa, cosa resta fuori, periodo, fonte dei dati ed esempio.
«90% di presenze del mese» usa ora i convocati dell'evento come posti (tutta la
rosa se non ce ne sono), come le presenze collettive: chi non è convocato non
abbassa più la percentuale. Test unit, card e integrazione; docs aggiornate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-10-06 11:34:58 +02:00
1 parent 6b8cc33512
commit c2c3b249f2
10 files changed
+779 -73

No files matched your search

+60
View File
@@ -468,6 +468,66 @@ if (!locale) {
},
);
await prova(
"o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no",
async () => {
// Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa:
// g1 presente, g2 assente (presenza persa), g3 in ritardo. g4 non è convocato ma risponde
// «assente»: non deve pesare. La cena del mese ha risposte «assente» e non deve contare.
const allenamentoId = `${PREFISSO}-o1-convocati-allenamento`;
const cenaId = `${PREFISSO}-o1-convocati-cena`;
const [g1, g2, g3, g4] = giocatori;
for (const [id, tipo, data, convocati] of [
[allenamentoId, "allenamento", "2099-03-12", [g1!.id, g2!.id, g3!.id]],
[cenaId, "evento", "2099-03-14", [g1!.id, g2!.id, g3!.id]],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id,
tipo,
titolo: `Test o1 convocati (${tipo})`,
data,
convocati,
}),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
const righe = [
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "assente" },
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "ritardo" },
{ evento_id: allenamentoId, giocatore_id: g4!.id, stato: "assente" },
{ evento_id: cenaId, giocatore_id: g1!.id, stato: "assente" },
{ evento_id: cenaId, giocatore_id: g2!.id, stato: "assente" },
];
const inserite = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
const ids = [allenamentoId, cenaId];
const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id));
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(cenaId)),
};
const o1 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o1")!;
assert.equal(
o1.valore,
67,
"2 presenti (presente e ritardo) su 3 convocati; g4 non convocato e la cena non contano",
);
},
);
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
const matchId = `${PREFISSO}-o12-m1`;
const [g1, g2, g3] = giocatori;
+1
View File
@@ -69,6 +69,7 @@ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
unita: "%",
emoji: "🎯",
impatto: "",
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
});
assert.deepEqual(
ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")),
+106
View File
@@ -0,0 +1,106 @@
/** Check della card di dettaglio degli obiettivi: `bun test/unit/obiettivi-dettaglio.test.ts`. */
import assert from "node:assert/strict";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SchedaObiettivo } from "@/components/crapp/ObiettivoDrawer";
import { Drawer } from "@/components/ui/drawer";
import { giocatori } from "@/lib/crapp-data";
import { contestoVuoto, obiettiviSquadra, type ObiettivoSquadra } from "@/lib/obiettivi";
const OGGI = new Date("2026-08-15T10:00:00Z");
const tutti = obiettiviSquadra(giocatori, contestoVuoto, OGGI);
/** Rende la scheda dentro un Drawer aperto, come fa l'app (titolo e descrizione ne hanno bisogno). */
const scheda = (o: ObiettivoSquadra) =>
renderToStaticMarkup(
createElement(Drawer, { open: true }, createElement(SchedaObiettivo, { o })),
);
// --- ogni obiettivo ha tutti i testi, nessun campo vuoto ----------------------------
const ids = tutti.map((o) => o.id).sort();
assert.deepEqual(
ids,
["o1", "o11", "o12", "o13", "o2", "o3", "o4", "o5", "o6", "o7"],
"gli obiettivi sono quelli attesi: se ne aggiungi uno, scrivi il suo dettaglio",
);
for (const o of tutti) {
const d = o.dettaglio;
for (const [nome, testo] of Object.entries({
comeSiCalcola: d.comeSiCalcola,
periodo: d.periodo,
fonte: d.fonte,
esempio: d.esempio,
})) {
assert.ok(testo.trim().length > 20, `${o.id}: «${nome}» troppo corto o vuoto`);
}
for (const voce of [...d.conta, ...d.nonConta, ...(d.perse ?? [])]) {
assert.ok(voce.trim().length > 10, `${o.id}: voce di elenco vuota o troppo corta`);
}
assert.equal(new Set(d.conta).size, d.conta.length, `${o.id}: voci «conta» duplicate`);
assert.equal(new Set(d.nonConta).size, d.nonConta.length, `${o.id}: voci «non conta» duplicate`);
}
// --- «presenza persa» solo sulle due percentuali di presenze ------------------------
for (const o of tutti) {
const haPerse = Boolean(o.dettaglio.perse && o.dettaglio.perse.length > 0);
assert.equal(haPerse, o.id === "o1" || o.id === "o7", `${o.id}: presenza persa solo su o1 e o7`);
}
// --- le due presenze spiegano gli stessi punti chiave -------------------------------
for (const id of ["o1", "o7"]) {
const d = tutti.find((o) => o.id === id)!.dettaglio;
const tutto = [d.comeSiCalcola, ...d.conta, ...(d.perse ?? []), ...d.nonConta].join(" ");
for (const parola of ["convocat", "presente", "in ritardo", "assente", "cene"]) {
assert.ok(tutto.toLowerCase().includes(parola), `${id}: la spiegazione non cita «${parola}»`);
}
assert.ok(
d.nonConta.some((v) => /non è convocato/i.test(v)),
`${id}: dice che il non convocato non pesa`,
);
}
const mese = tutti.find((o) => o.id === "o1")!.dettaglio;
const totale = tutti.find((o) => o.id === "o7")!.dettaglio;
assert.ok(/mese/i.test(mese.periodo) && /azzera/i.test(mese.periodo), "o1: si azzera ogni mese");
assert.ok(/stagione/i.test(totale.periodo) && /mai/i.test(totale.periodo), "o7: non si azzera mai");
// --- la card renderizzata mostra tutte le sezioni ------------------------------------
const o1 = tutti.find((o) => o.id === "o1")!;
const html1 = scheda(o1);
for (const titolo of [
"Stato attuale",
"Come si calcola",
"Cosa conta",
"Presenza persa",
"Non entra nel calcolo",
"Quando vale",
"Da dove arrivano i dati",
"Un esempio",
]) {
assert.ok(html1.includes(titolo), `la card di o1 non mostra «${titolo}»`);
}
assert.ok(html1.includes(o1.titolo.replace(/&/g, "&amp;")), "titolo");
assert.ok(html1.includes(o1.dettaglio.comeSiCalcola), "testo «come si calcola»");
assert.ok(html1.includes(o1.impatto), "frase di impatto");
assert.ok(html1.includes("entro il"), "o1 ha una scadenza mensile");
assert.ok(
!html1.includes("Cosa non conta"),
"con «presenza persa» la sezione si chiama diversamente",
);
// Un obiettivo senza «presenza persa» ha «Cosa non conta» e nessuna sezione in più.
const o12 = tutti.find((o) => o.id === "o12")!;
const html12 = scheda(o12);
assert.ok(html12.includes("Cosa non conta"), "o12: «Cosa non conta»");
assert.ok(!html12.includes("Presenza persa"), "o12: nessuna «presenza persa»");
assert.ok(!html12.includes("Non entra nel calcolo"), "o12: nessun «non entra nel calcolo»");
assert.ok(!html12.includes("entro il"), "o12 non ha scadenza");
// --- stato: in corso / completato, e i numeri mostrati --------------------------------
const finto = (valore: number): ObiettivoSquadra => ({ ...o12, valore, target: 10 });
assert.ok(scheda(finto(4)).includes("In corso"), "sotto il target: in corso");
assert.ok(scheda(finto(4)).includes("40%"), "percentuale mostrata");
assert.ok(scheda(finto(10)).includes("Completato"), "al target: completato");
assert.ok(!scheda(finto(10)).includes("In corso"), "al target non è più in corso");
console.log("obiettivi-dettaglio: ok");
+177
View File
@@ -100,6 +100,169 @@ const soloCompleanni: ContestoObiettivi = {
};
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0);
// --- o1: chi non è convocato non occupa un posto, la sua assenza non pesa -------
{
const [g1, g2] = giocatori;
const mese: ContestoObiettivi = {
...contestoVuoto,
eventi: [{ ...evento("m1", "2026-08-10", "allenamento"), convocati: [g1!.id, g2!.id] }],
presenze: { m1: { [g1!.id]: "presente", [g2!.id]: "assente" } },
};
assert.equal(
trova(obiettiviSquadra(giocatori, mese, OGGI_AGOSTO), "o1").valore,
50,
"su 2 convocati 1 presente e 1 assente: 50%, il resto della rosa non conta",
);
assert.equal(
trova(
obiettiviSquadra(
giocatori,
{ ...mese, eventi: [evento("m1", "2026-08-10", "allenamento")] },
OGGI_AGOSTO,
),
"o1",
).valore,
Math.round((1 / giocatori.length) * 100),
"senza convocati i posti sono tutta la rosa",
);
}
// --- presenze (o1 mese, o7 totale): casi limite del calcolo ------------------------
{
const [g1, g2, g3, g4] = giocatori;
const P = (...x: [string, "presente" | "ritardo" | "assente" | "forse" | "infortunato"][]) =>
Object.fromEntries(x);
const o1v = (c: ContestoObiettivi, oggi = OGGI_AGOSTO) =>
trova(obiettiviSquadra(giocatori, c, oggi), "o1").valore;
const o7v = (c: ContestoObiettivi) => trova(obiettiviSquadra(giocatori, c), "o7").valore;
const conv = (id: string, data: string, tipo: Evento["tipo"], convocati: string[]) => ({
...evento(id, data, tipo),
convocati,
});
// Solo allenamenti e partite entrano nel conto: cene, eventi e compleanni restano fuori
// anche se hanno risposte di presenza.
const conAltri: ContestoObiettivi = {
...contestoVuoto,
eventi: [
conv("t1", "2026-08-05", "allenamento", [g1!.id, g2!.id]),
conv("t2", "2026-08-06", "evento", [g1!.id, g2!.id]),
conv("t3", "2026-08-07", "compleanno", [g1!.id, g2!.id]),
],
presenze: {
t1: P([g1!.id, "presente"], [g2!.id, "assente"]),
t2: P([g1!.id, "assente"], [g2!.id, "assente"]),
t3: P([g1!.id, "presente"], [g2!.id, "presente"]),
},
};
assert.equal(o1v(conAltri), 50, "o1: solo l'allenamento conta (1 su 2)");
assert.equal(o7v(conAltri), 50, "o7: solo l'allenamento conta (1 su 2)");
// Assente, forse, infortunato e nessuna risposta sono tutti presenze perse: posto vuoto.
const perse: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("p1", "2026-08-05", "partita", [g1!.id, g2!.id, g3!.id, g4!.id])],
presenze: { p1: P([g1!.id, "assente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) },
};
assert.equal(o1v(perse), 0, "o1: nessuna presenza, 0 su 4");
assert.equal(o7v(perse), 0, "o7: nessuna presenza, 0 su 4");
const unaPresente = {
...perse,
presenze: { p1: P([g1!.id, "presente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) },
};
assert.equal(o1v(unaPresente), 25, "o1: 1 presente su 4 convocati");
assert.equal(o7v(unaPresente), 25, "o7: 1 presente su 4 convocati");
// Un non convocato che risponde non gonfia la percentuale, e se è assente non la abbassa.
const nonConv: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("n1", "2026-08-05", "allenamento", [g1!.id])],
presenze: { n1: P([g1!.id, "presente"], [g2!.id, "assente"], [g3!.id, "presente"]) },
};
assert.equal(o1v(nonConv), 100, "o1: l'assente non convocato non pesa, il presente nemmeno");
assert.equal(o7v(nonConv), 100, "o7: idem");
// Il mese e il totale coincidono quando tutti gli eventi sono del mese corrente;
// il totale invece comprende anche gli altri mesi.
const dueMesi: ContestoObiettivi = {
...contestoVuoto,
eventi: [
conv("m1", "2026-08-05", "allenamento", [g1!.id, g2!.id]),
conv("m2", "2026-07-05", "allenamento", [g1!.id, g2!.id]),
],
presenze: {
m1: P([g1!.id, "presente"], [g2!.id, "presente"]),
m2: P([g1!.id, "assente"], [g2!.id, "assente"]),
},
};
assert.equal(o1v(dueMesi), 100, "o1: guarda solo agosto");
assert.equal(o7v(dueMesi), 50, "o7: guarda agosto e luglio insieme");
assert.equal(o1v(dueMesi, new Date("2026-07-15T10:00:00Z")), 0, "o1 a luglio: solo luglio");
const soloAgosto = { ...dueMesi, eventi: [dueMesi.eventi[0]!] };
assert.equal(o1v(soloAgosto), o7v(soloAgosto), "stessi eventi: stessa percentuale");
// Confine di mese nel fuso Europe/Rome: il 30 giugno alle 22:30 UTC a Roma è già luglio.
const sera = new Date("2026-06-30T22:30:00Z");
const luglio: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("l1", "2026-07-01", "allenamento", [g1!.id])],
presenze: { l1: P([g1!.id, "presente"]) },
};
assert.equal(o1v(luglio, sera), 100, "a Roma è già il 1° luglio: l'evento di luglio conta");
const giugno = { ...luglio, eventi: [conv("l1", "2026-06-30", "allenamento", [g1!.id])] };
assert.equal(o1v(giugno, sera), 0, "a Roma giugno è finito: l'evento del 30 giugno non conta");
// Convocato sconosciuto (non in rosa): nessun posto, quindi 0 e non NaN.
const fantasma: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("f1", "2026-08-05", "allenamento", ["fantasma"])],
presenze: { f1: P(["fantasma", "presente"]) },
};
assert.equal(o1v(fantasma), 0, "o1: convocato fuori rosa, 0 posti");
assert.equal(o7v(fantasma), 0, "o7: convocato fuori rosa, 0 posti");
// Evento senza nessuna risposta: i posti ci sono, le presenze no.
const senzaRisposte: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("r1", "2026-08-05", "allenamento", [g1!.id, g2!.id])],
};
assert.equal(o1v(senzaRisposte), 0, "o1: nessuna risposta = 0%");
// Rosa vuota: nessuna divisione per zero.
assert.equal(
trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o1").valore,
0,
"rosa vuota: o1",
);
assert.equal(
trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o7").valore,
0,
"rosa vuota: o7",
);
// Arrotondamento: 2 su 3 = 67%, e il progresso non supera mai il 100%.
const treConvocati: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("a3", "2026-08-05", "allenamento", [g1!.id, g2!.id, g3!.id])],
presenze: { a3: P([g1!.id, "presente"], [g2!.id, "ritardo"], [g3!.id, "assente"]) },
};
assert.equal(o1v(treConvocati), 67, "o1: 2 su 3 arrotondato");
assert.equal(o7v(treConvocati), 67, "o7: 2 su 3 arrotondato");
// Soglia del 90%: la percentuale arriva al target e l'obiettivo risulta completato.
const dieci = giocatori.slice(0, 10).map((g) => g.id);
const novePresenti: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("d1", "2026-08-05", "allenamento", dieci)],
presenze: {
d1: Object.fromEntries(dieci.map((id, i) => [id, i === 0 ? "assente" : "presente"])),
},
};
const ob1 = trova(obiettiviSquadra(giocatori, novePresenti, OGGI_AGOSTO), "o1");
assert.equal(ob1.valore, 90, "9 su 10 = 90%");
assert.equal(progressoObiettivo(ob1), 100, "90% su target 90% = completato");
}
// --- o1: si azzera a ogni cambio mese, in base agli eventi a calendario ------
{
// Stesso evento/presenze: "in mese" a settembre, "fuori mese" se letto da agosto.
@@ -529,6 +692,7 @@ const o = (valore: number, target: number): ObiettivoSquadra => ({
unita: "%",
emoji: "🎯",
impatto: "i",
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
});
assert.equal(progressoObiettivo(o(0, 10)), 0);
assert.equal(progressoObiettivo(o(5, 10)), 50);
@@ -558,4 +722,17 @@ assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.")
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo.");
// --- dettaglio: ogni obiettivo spiega tutto, senza campi vuoti ---------------------
for (const ob of obiettiviSquadra(giocatori, contestoVuoto)) {
const d = ob.dettaglio;
for (const campo of [d.comeSiCalcola, d.periodo, d.fonte, d.esempio]) {
assert.ok(campo.trim().length > 0, `${ob.id}: testo di dettaglio vuoto`);
}
assert.ok(d.conta.length > 0, `${ob.id}: manca «cosa conta»`);
assert.ok(d.nonConta.length > 0, `${ob.id}: manca «cosa non conta»`);
if (ob.id === "o1" || ob.id === "o7") {
assert.ok(d.perse && d.perse.length > 0, `${ob.id}: manca «presenza persa»`);
}
}
console.log("obiettivi: ok");