From 6b8cc33512b75cacceebf1617dcd2c5fb9810c2e Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 6 Oct 2026 11:06:51 +0200 Subject: [PATCH] Obiettivo Presenze collettive: percentuale al 90% su allenamenti e partite definiti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sostituisce «250 presenze complessive». Il valore è la percentuale di presenze (presente o ritardo) su tutti gli allenamenti e le partite in calendario, passati e futuri, con i convocati come posti (tutta la rosa se non ce ne sono). Stagione intera, nessun reset mensile. Test unitari ampliati (arrotondamento, convocati, ora legale, cambio mese) e test di integrazione di o6 riallineato alla regola dell'ora dell'evento. Documentazione e CHANGELOG aggiornati. Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 2 + docs/modules/obiettivi-squadra.md | 36 +++--- src/lib/obiettivi.ts | 32 ++++- test/integration/obiettivi.test.ts | 183 ++++++++++++++++------------- test/unit/obiettivi.test.ts | 120 ++++++++++++++++--- 5 files changed, 254 insertions(+), 119 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index dccbd8b..9115080 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -24,6 +24,8 @@ versioni `0.y.z` erano pre-release. - **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration. +- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la + rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. Nessuna migration. ## [1.2.3] - 2026-10-04 diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 2c8ee75..4306ac3 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -37,22 +37,22 @@ corrente dinamico (vedi sotto). ## Obiettivi definiti -| id | Obiettivo | Calcolo | Target | Fonte | -| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- | -| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | -| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | -| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` | -| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | -| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | -| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | -| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | -| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | -| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | -| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` | +| id | Obiettivo | Calcolo | Target | Fonte | +| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- | +| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | +| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` | +| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` | +| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` | +| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | +| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` | +| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) | +| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI | +| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI | +| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` | Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica -smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10 +smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10 vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra" (vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri. @@ -73,6 +73,12 @@ un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/ 0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il passaggio a completato compare al refresh successivo. +`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli +allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono +conferme). Differisce da `o1` per periodo (stagione, non mese) e perché i posti sono i convocati +di ciascun evento, non sempre l'intera rosa. Non c'è un confine di stagione esplicito: conta ciò +che c'è in `eventi_app`. + `o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa. @@ -163,8 +169,8 @@ da un backend reale, non solo funzione pura con contesto costruito a mano). query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi, filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico (evento dentro/fuori mese), scadenza dinamica. -- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la - stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma. +- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali, + li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta). - **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola `serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato. - **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index a1260be..286b5d7 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -80,6 +80,27 @@ function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: return Math.round((presenti / posti) * 100); } +/** + * Presenze (presente o ritardo) su tutti gli allenamenti e le partite definiti, passati e + * futuri, sul totale dei posti: per ogni evento i convocati, o tutta la rosa se non ce ne sono. + */ +function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) { + let posti = 0; + let presenti = 0; + for (const e of ctx.eventi) { + if (e.tipo !== "partita" && e.tipo !== "allenamento") continue; + const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id)); + posti += attesi.length; + const risposte = ctx.presenze[e.id] ?? {}; + presenti += attesi.filter((g) => { + const stato = risposte[g.id]; + return stato === "presente" || stato === "ritardo"; + }).length; + } + if (posti === 0) return 0; + return Math.round((presenti / posti) * 100); +} + function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) { const daRispondere = ctx.eventi.filter((e) => e.tipo !== "compleanno"); if (daRispondere.length === 0 || rosaSize === 0) return 0; @@ -97,7 +118,6 @@ export function obiettiviSquadra( ctx: ContestoObiettivi = contestoVuoto, oggi: Date = new Date(), ): ObiettivoSquadra[] { - const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0); const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length; const vittorie = ctx.vittorie ?? 0; const mese = meseCorrente(oggi); @@ -126,11 +146,11 @@ export function obiettiviSquadra( }, { id: "o7", - titolo: "250 presenze complessive", - descrizione: "Somma delle presenze di tutta la rosa in stagione", - valore: somma((g) => g.presenze), - target: 250, - unita: "presenze", + titolo: "Presenze collettive", + descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione", + valore: percentualePresenzeCollettive(ctx, rosa), + target: 90, + unita: "%", emoji: "🤝", impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.", }, diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts index 232780c..3b14cdb 100644 --- a/test/integration/obiettivi.test.ts +++ b/test/integration/obiettivi.test.ts @@ -2,7 +2,7 @@ * Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`. * Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al * mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti - * su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle + * su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle * da 7.5" e "200 pagelle compilate" via `pagelle_voti`). * * I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; import { giocatori } from "@/lib/crapp-data"; import { obiettiviSquadra } from "@/lib/obiettivi"; -import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze"; +import { serieConsecutiva, type MappaPresenze } from "@/lib/presenze"; import { statoLocale } from "../helpers/locale"; import { prova, riepilogo, salta } from "../helpers/prova"; @@ -282,33 +282,51 @@ if (!locale) { "o6 legge dal database l'evento sociale del mese, azzerandosi come o1", async () => { const pizzataId = `${PREFISSO}-pizzata`; - const inserita = await rest("eventi_app", { - method: "POST", - body: JSON.stringify({ - id: pizzataId, - tipo: "evento", - titolo: "Test obiettivi o6", - data: `${MESE_TEST}-20`, - }), - }); - if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`); + const pizzataFuturaId = `${PREFISSO}-pizzata-futura`; + // OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no. + for (const [id, giorno] of [ + [pizzataId, "10"], + [pizzataFuturaId, "20"], + ] as const) { + const inserita = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id, + tipo: "evento", + titolo: "Test obiettivi o6", + data: `${MESE_TEST}-${giorno}`, + ora: "20:30", + }), + }); + if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`); + } - const eventiReali = await leggiEventi(); - const o6InMese = obiettiviSquadra( - giocatori, - { eventi: eventiReali, presenze: {}, pagelle: [] }, - OGGI, - ).find((o) => o.id === "o6")!; - assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database"); + const tutti = await leggiEventi(); + const o6Di = (ids: string[], oggi: Date) => + obiettiviSquadra( + giocatori, + { eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] }, + oggi, + ).find((o) => o.id === "o6")!; - const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z"); - const o6MeseDopo = obiettiviSquadra( - giocatori, - { eventi: eventiReali, presenze: {}, pagelle: [] }, - OGGI_MESE_DOPO, - ).find((o) => o.id === "o6")!; assert.equal( - o6MeseDopo.valore, + o6Di([pizzataId], OGGI).valore, + 1, + "l'evento sociale già passato conta, letto dal database", + ); + assert.equal( + o6Di([pizzataFuturaId], OGGI).valore, + 0, + "l'evento sociale futuro non conta finché non arriva la sua ora", + ); + // Il 20 marzo 2099 alle 20:30 Roma (CET, UTC+1) = 19:30Z: da lì conta. + assert.equal( + o6Di([pizzataFuturaId], new Date("2099-03-20T19:30:00Z")).valore, + 1, + "arrivata l'ora dell'evento conta", + ); + assert.equal( + o6Di([pizzataId], new Date("2099-04-15T10:00:00Z")).valore, 0, "lo stesso evento non conta più il mese successivo (si azzera)", ); @@ -386,68 +404,69 @@ if (!locale) { }, ); - await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => { - // o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da - // `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita - // la stessa funzione pura sui dati appena scritti, per verificare l'intera catena - // DB -> contaPresenzeGiocatore -> o7, non solo la somma finale. - const allenamentoId = `${PREFISSO}-o7-allenamento`; - const partitaId = `${PREFISSO}-o7-partita`; - const OGGI_STR = "2099-01-20"; + await prova( + "o7 percentuale di presenze collettive da eventi/risposte reali del database", + async () => { + // o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i + // convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7. + const allenamentoId = `${PREFISSO}-o7-allenamento`; + const partitaId = `${PREFISSO}-o7-partita`; + const [g1, g2, g3] = giocatori; - for (const [id, tipo, data] of [ - [allenamentoId, "allenamento", "2099-01-05"], - [partitaId, "partita", "2099-01-08"], - ] as const) { - const inserito = await rest("eventi_app", { + for (const [id, tipo, data] of [ + [allenamentoId, "allenamento", "2099-01-05"], + [partitaId, "partita", "2099-01-08"], + ] as const) { + const inserito = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id, + tipo, + titolo: `Test obiettivi o7 (${tipo})`, + data, + convocati: [g1!.id, g2!.id, g3!.id], + }), + }); + if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); + } + + // g1 presente a entrambi, g2 presente e in ritardo (il ritardo conta), g3 assente a + // entrambi: 4 presenze su 6 posti (3 convocati x 2 eventi) = 67%. + const righe = [ + { evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" }, + { evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" }, + { evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" }, + { evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" }, + { evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" }, + { evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" }, + ]; + const inserite = await rest("risposte_presenze", { method: "POST", - body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }), + body: JSON.stringify(righe), }); - if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); - } + if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`); - // g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze, - // il ritardo conta). g3: assente a entrambi (0 presenze). - const [g1, g2, g3] = giocatori; - const righe = [ - { evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" }, - { evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" }, - { evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" }, - { evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" }, - { evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" }, - { evento_id: partitaId, giocatore_id: g3!.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 eventiReali = (await leggiEventi()).filter( + (e) => e.id === allenamentoId || e.id === partitaId, + ); + const presenzeReali = { + ...(await leggiPresenze(allenamentoId)), + ...(await leggiPresenze(partitaId)), + }; - const eventiReali = (await leggiEventi()).filter( - (e) => e.id === allenamentoId || e.id === partitaId, - ); - const presenzeReali = { - ...(await leggiPresenze(allenamentoId)), - ...(await leggiPresenze(partitaId)), - }; + const o7 = obiettiviSquadra([g1!, g2!, g3!], { + eventi: eventiReali, + presenze: presenzeReali, + pagelle: [], + }).find((o) => o.id === "o7")!; - const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({ - ...g, - presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR), - })); - - const o7 = obiettiviSquadra(rosaConPresenzeReali, { - eventi: [], - presenze: {}, - pagelle: [], - }).find((o) => o.id === "o7")!; - - assert.equal( - o7.valore, - 4, - "g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database", - ); - }); + assert.equal( + o7.valore, + 67, + "4 presenze su 6 posti (il ritardo conta), calcolate dal database", + ); + }, + ); await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => { const matchId = `${PREFISSO}-o12-m1`; diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 85d3634..7f68d02 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -274,6 +274,32 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 0, "evento senza ora: la sera prima (Roma) non conta", ); + // Ora legale: il 20/09 vale CEST (UTC+2), il 20/12 CET (UTC+1): 20:30 Roma = 18:30Z / 19:30Z. + const inverno = { ...evento("iv1", "2026-12-20", "evento"), ora: "20:30" }; + const ctxInverno: ContestoObiettivi = { eventi: [inverno], presenze: {}, pagelle: [] }; + assert.equal( + trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:29:00Z")), "o6").valore, + 0, + "inverno: 19:29Z = 20:29 a Roma, non ancora", + ); + assert.equal( + trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:30:00Z")), "o6").valore, + 1, + "inverno: 19:30Z = 20:30 a Roma, conta", + ); + // Cambio mese a mezzanotte di Roma: 30/09 23:30Z è già 01/10 a Roma (CEST). + const fineSett = { ...evento("fs1", "2026-09-30", "evento"), ora: "20:00" }; + const ctxFine: ContestoObiettivi = { eventi: [fineSett], presenze: {}, pagelle: [] }; + assert.equal( + trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T21:59:00Z")), "o6").valore, + 1, + "30/09 23:59 a Roma: ancora settembre, conta", + ); + assert.equal( + trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T22:00:00Z")), "o6").valore, + 0, + "01/10 00:00 a Roma: nuovo mese, si azzera", + ); // Più eventi: ne basta uno passato; quello futuro non si somma. const misti: ContestoObiettivi = { eventi: [evento("m1", "2026-09-05", "evento"), evento("m2", "2026-09-28", "evento")], @@ -298,23 +324,85 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2 ); } -// --- somme sulla rosa -------------------------------------------------------- -const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0); -assert.equal(trova(vuoti, "o7").valore, sommaPresenze); -assert.equal( - trova(obiettiviSquadra([], contestoVuoto), "o7").valore, - 0, - "rosa vuota: nessuna presenza", -); +// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) --- +{ + const [g1, g2, g3, g4] = giocatori; + const rosa4 = [g1!, g2!, g3!, g4!]; + const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7"); -// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula -// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30). -const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! })); -assert.equal( - trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore, - 30, - "somma di presenze note, indipendente dal roster reale", -); + assert.equal(o7(contestoVuoto).titolo, "Presenze collettive"); + assert.equal(o7(contestoVuoto).target, 90); + assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN"); + assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0"); + + // Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%. + const base: ContestoObiettivi = { + eventi: [evento("c1", "2026-07-01", "allenamento"), evento("c2", "2026-12-01", "partita")], + presenze: { + c1: { [g1!.id]: "presente", [g2!.id]: "ritardo", [g3!.id]: "presente", [g4!.id]: "assente" }, + c2: { [g1!.id]: "presente", [g2!.id]: "assente" }, + }, + pagelle: [], + }; + assert.equal(o7(base).valore, 50, "passati e futuri nel denominatore, ritardo conta"); + + // Con convocati il denominatore sono solo loro: c1 per g1 e g2 (2 presenti su 2) = 100%. + const conConvocati: ContestoObiettivi = { + ...base, + eventi: [{ ...evento("c1", "2026-07-01", "allenamento"), convocati: [g1!.id, g2!.id] }], + }; + assert.equal(o7(conConvocati).valore, 100, "denominatore = convocati"); + + // Un non convocato che risponde "presente" non gonfia la percentuale. + const nonConvocato: ContestoObiettivi = { + ...conConvocati, + presenze: { c1: { [g1!.id]: "presente", [g3!.id]: "presente" } }, + }; + assert.equal(o7(nonConvocato).valore, 50, "i non convocati non contano (1 su 2)"); + + // Arrotondamento: 179 presenze su 200 posti = 89,5% -> 90; 178 su 200 = 89%. + const rosa100 = Array.from({ length: 100 }, (_, i) => ({ ...g1!, id: `r${i}` })); + const rispondi = (presenti: number) => + Object.fromEntries(rosa100.map((g, i) => [g.id, i < presenti ? "presente" : "assente"])); + const duePartite = (a: number, b: number): ContestoObiettivi => ({ + eventi: [evento("t1", "2026-07-01", "allenamento"), evento("t2", "2026-07-02", "allenamento")], + presenze: { + t1: rispondi(a) as Record, + t2: rispondi(b) as Record, + }, + pagelle: [], + }); + assert.equal(o7(duePartite(90, 89), rosa100).valore, 90, "89,5% arrotonda a 90"); + assert.equal(o7(duePartite(90, 88), rosa100).valore, 89, "89% resta 89"); + assert.equal(o7(duePartite(100, 100), rosa100).valore, 100); + + // Evento privo di risposte: pesa nel denominatore ma non nel numeratore. + const senzaRisposte: ContestoObiettivi = { + eventi: [evento("n1", "2026-07-01", "allenamento")], + presenze: {}, + pagelle: [], + }; + assert.equal(o7(senzaRisposte).valore, 0, "nessuna risposta: 0%"); + + // Convocati che non sono in rosa: non entrano nei posti. + const fuoriRosa: ContestoObiettivi = { + eventi: [{ ...evento("f1", "2026-07-01", "allenamento"), convocati: ["sconosciuto"] }], + presenze: {}, + pagelle: [], + }; + assert.equal(o7(fuoriRosa).valore, 0, "nessun posto valido: 0, non NaN"); + + // Eventi sociali e compleanni non fanno parte del denominatore. + const sociali: ContestoObiettivi = { + ...base, + eventi: [ + ...base.eventi, + evento("c3", "2026-07-10", "evento"), + evento("c4", "2026-07-11", "compleanno"), + ], + }; + assert.equal(o7(sociali).valore, 50, "solo allenamenti e partite"); +} const pagelle: VotoPagella[] = [ { match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },