diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 417191a..57466af 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -1,6 +1,7 @@ # Modulo — Obiettivi di squadra -**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano +**Stato:** implementato (v1.0), con target stagionali da aggiornare a mano (i mesi di +riferimento sono ora dinamici) **File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`) --- @@ -25,14 +26,14 @@ serie). | Obiettivo | Calcolo | Target | Fonte | | ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- | -| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` | +| 90% presenze del mese | risposte presente/ritardo sugli eventi del mese corrente (dinamico) | 90% | `risposte_presenze` | | Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` | | 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` | | Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` | | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` | | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` | | 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) | -| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` | +| 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (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 @@ -46,9 +47,6 @@ smart (`notifiche-smart.ts`). [Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la squadra risponde alle convocazioni, non solo la presenza. -- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi - legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi - sono "congelati" su un mese già passato. - Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali. - I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a @@ -56,6 +54,13 @@ smart (`notifiche-smart.ts`). --- -## Evoluzioni possibili +## Obiettivi mensili — mese dinamico -- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded. +"90% presenze del mese" e "1 evento di squadra al mese" si azzerano automaticamente a ogni +cambio mese: il mese di riferimento è calcolato dalla data corrente (fuso Europe/Rome), non più +una costante fissa. Per il primo, titolo ("90% di presenze ad agosto" / "a settembre" / ...) e +scadenza (ultimo giorno del mese) seguono di conseguenza. Per i test, `obiettiviSquadra`/ +`obiettiviOrdinati` accettano un terzo parametro opzionale `oggi: Date` per iniettare una data +deterministica. Coperti sia da unit test (`test/unit/obiettivi.test.ts`, funzione pura) sia da +un integration test end-to-end (`test/integration/obiettivi.test.ts`, scrive/rilegge righe vere +su `eventi_app` e `risposte_presenze` sullo stack Supabase locale). diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index 900a780..9e33e65 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -26,11 +26,32 @@ export type ContestoObiettivi = { export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] }; -const MESE = "2026-08"; +/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */ +function meseCorrente(oggi: Date): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7); +} -function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) { +/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */ +function aMese(oggi: Date): string { + const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format( + oggi, + ); + const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a"; + return `${preposizione} ${nome}`; +} + +/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */ +function fineMese(oggi: Date): string { + const mese = meseCorrente(oggi); + const anno = Number(mese.slice(0, 4)); + const numeroMese = Number(mese.slice(5, 7)); + const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate(); + return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`; +} + +function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) { const delMese = ctx.eventi.filter( - (e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"), + (e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"), ); if (delMese.length === 0 || rosaSize === 0) return 0; const posti = delMese.length * rosaSize; @@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) { export function obiettiviSquadra( rosa: Giocatore[] = giocatori, 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); return [ { id: "o1", - titolo: "90% di presenze ad agosto", + titolo: `90% di presenze ${aMese(oggi)}`, descrizione: "Media presenze su partite e allenamenti del mese", - valore: percentualePresenzeMese(ctx, rosa.length), + valore: percentualePresenzeMese(ctx, rosa.length, mese), target: 90, unita: "%", - scadenza: "2026-08-31", + scadenza: fineMese(oggi), emoji: "📣", impatto: "Ogni sì in più alza la media di tutta la squadra.", }, @@ -157,7 +180,7 @@ export function obiettiviSquadra( id: "o6", titolo: "1 evento di squadra al mese", descrizione: "Pizzate, cene e uscite fuori dal campo", - valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length, + valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length, target: 1, unita: "eventi", emoji: "🍕", @@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) { return Math.min(100, Math.round((o.valore / o.target) * 100)); } -export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) { - return obiettiviSquadra(rosa, ctx).sort((a, b) => { +export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) { + return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => { const pa = progressoObiettivo(a); const pb = progressoObiettivo(b); const ca = pa >= 100 ? 1 : 0; diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts new file mode 100644 index 0000000..a3a1c68 --- /dev/null +++ b/test/integration/obiettivi.test.ts @@ -0,0 +1,215 @@ +/** + * Obiettivi mensili di squadra ("presenze del mese" e "evento di squadra al mese") + * end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`. + * + * I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione + * pura, con un `ContestoObiettivi` costruito a mano. Qui invece si scrivono righe vere su + * `eventi_app` e `risposte_presenze`, si rileggono con `leggiEventi()` (la stessa funzione che + * usa l'app lato server) e una query REST equivalente a `fetchPresenze()`, e si verifica che il + * risultato che arriva a `obiettiviSquadra()` sia quello atteso: se una colonna cambia nome o + * la mappatura si rompe, qui il valore torna sbagliato (o NaN) anche se i test unitari restano + * verdi, perché quelli non toccano mai il database. + * + * Il mese di test è marzo 2099: non collide con gli eventi del seed (tutti in agosto 2026), così + * questi due obiettivi — che ora aggregano su TUTTI gli eventi del mese corrente, non solo + * quelli del test — restano prevedibili senza dover filtrare gli eventi letti dal database. + * + * Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine: + * usa id con il prefisso `test-obiettivi`, che nessun dato vero può avere. + */ +import assert from "node:assert/strict"; +import { giocatori } from "@/lib/crapp-data"; +import { obiettiviSquadra } from "@/lib/obiettivi"; +import type { MappaPresenze } from "@/lib/presenze"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("obiettivi (o1) sul database", "stack locale non attivo (npx supabase start)"); + riepilogo("obiettivi"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`obiettivi su ${URL_BASE}`); + + // Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`. + process.env["SUPABASE_URL"] = URL_BASE; + process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; + + const { leggiEventi } = await import("@/lib/eventi.server"); + + const PREFISSO = "test-obiettivi"; + const MESE_TEST = "2099-03"; + const OGGI = new Date("2099-03-15T10:00:00Z"); + + const rest = (percorso: string, init?: RequestInit) => + fetch(`${URL_BASE}/rest/v1/${percorso}`, { + ...init, + headers: { + apikey: SERVIZIO, + Authorization: `Bearer ${SERVIZIO}`, + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + /** Stessa query di `fetchPresenze()` (`src/lib/presenze.ts`), raggruppata a mano. */ + async function leggiPresenze(eventoId: string): Promise { + const res = await rest( + `risposte_presenze?evento_id=eq.${eventoId}&select=evento_id,giocatore_id,stato`, + ); + const righe = (await res.json()) as Array<{ + evento_id: string; + giocatore_id: string; + stato: string; + }>; + const mappa: MappaPresenze = {}; + for (const r of righe) { + (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato as never; + } + return mappa; + } + + try { + const eventoId = `${PREFISSO}-e1`; + + await prova("prepara evento e presenze reali su Supabase locale", async () => { + const inserito = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id: eventoId, + tipo: "allenamento", + titolo: "Test obiettivi o1", + data: `${MESE_TEST}-10`, + }), + }); + if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`); + + // 9 presenti su 17 giocatori del roster reale (`crapp-data.ts`, id g1..g17). + const presenti = giocatori.slice(0, 9).map((g) => g.id); + const righe = giocatori.map((g) => ({ + evento_id: eventoId, + giocatore_id: g.id, + stato: presenti.includes(g.id) ? "presente" : "assente", + })); + const inseritePresenze = await rest("risposte_presenze", { method: "POST", body: JSON.stringify(righe) }); + if (!inseritePresenze.ok) { + throw new Error(`inserimento presenze fallito: ${await inseritePresenze.text()}`); + } + }); + + await prova( + "o1 legge l'evento e le presenze vere dal database e calcola la percentuale del mese", + async () => { + const eventiReali = await leggiEventi(); + assert.ok( + eventiReali.some((e) => e.id === eventoId), + "leggiEventi() include l'evento appena inserito", + ); + const presenzeReali = await leggiPresenze(eventoId); + + const obiettivi = obiettiviSquadra( + giocatori, + { eventi: eventiReali, presenze: presenzeReali, pagelle: [] }, + OGGI, + ); + const o1 = obiettivi.find((o) => o.id === "o1")!; + + assert.equal( + o1.valore, + Math.round((9 / giocatori.length) * 100), + "9 presenti su tutta la rosa, letti dal database", + ); + assert.equal(o1.titolo, "90% di presenze a marzo", "titolo segue il mese iniettato"); + assert.equal(o1.scadenza, "2099-03-31", "scadenza = ultimo giorno di marzo"); + }, + ); + + await prova("un evento fuori dal mese di test non sposta o1", async () => { + const eventoFuoriMese = `${PREFISSO}-e2`; + const fuori = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id: eventoFuoriMese, + tipo: "allenamento", + titolo: "Test obiettivi o1 - fuori mese", + data: "2099-04-01", + }), + }); + if (!fuori.ok) throw new Error(`inserimento evento fallito: ${await fuori.text()}`); + try { + const presenzeFuoriMese = giocatori.map((g) => ({ + evento_id: eventoFuoriMese, + giocatore_id: g.id, + stato: "presente", + })); + const scritte = await rest("risposte_presenze", { + method: "POST", + body: JSON.stringify(presenzeFuoriMese), + }); + if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`); + + const eventiReali = await leggiEventi(); + const presenzeReali = { + ...(await leggiPresenze(eventoId)), + ...(await leggiPresenze(eventoFuoriMese)), + }; + const o1 = obiettiviSquadra( + giocatori, + { eventi: eventiReali, presenze: presenzeReali, pagelle: [] }, + OGGI, + ).find((o) => o.id === "o1")!; + assert.equal( + o1.valore, + Math.round((9 / giocatori.length) * 100), + "l'evento di aprile non entra nel calcolo di marzo", + ); + } finally { + await rest(`eventi_app?id=eq.${eventoFuoriMese}`, { method: "DELETE" }); + await rest(`risposte_presenze?evento_id=eq.${eventoFuoriMese}`, { method: "DELETE" }); + } + }); + + await prova( + "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 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 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, + 0, + "lo stesso evento non conta più il mese successivo (si azzera)", + ); + }, + ); + } finally { + await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" }); + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + riepilogo("obiettivi"); + } +} diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 8152f4a..612a11e 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -30,8 +30,12 @@ const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({ const trova = (lista: ObiettivoSquadra[], id: string) => lista.find((o) => o.id === id)!; +// Data di riferimento fissa: o1 (presenze del mese) ora dipende dal mese corrente, +// quindi va iniettata esplicitamente per avere test deterministici. +const OGGI_AGOSTO = new Date("2026-08-15T10:00:00Z"); + // --- contesto vuoto: nessuna divisione per zero ------------------------------ -const vuoti = obiettiviSquadra(giocatori, contestoVuoto); +const vuoti = obiettiviSquadra(giocatori, contestoVuoto, OGGI_AGOSTO); assert.equal(trova(vuoti, "o1").valore, 0, "nessun evento nel mese: 0%, non NaN"); assert.equal(trova(vuoti, "o2").valore, 0); assert.equal(trova(vuoti, "o12").valore, 0, "nessuna pagella: media 0"); @@ -49,25 +53,32 @@ const ctx: ContestoObiettivi = { presenze: tuttiPresenti, pagelle: [], }; -assert.equal(trova(obiettiviSquadra(giocatori, ctx), "o1").valore, 100, "rosa al completo = 100%"); +assert.equal( + trova(obiettiviSquadra(giocatori, ctx, OGGI_AGOSTO), "o1").valore, + 100, + "rosa al completo = 100%", +); const metaRosa: MappaPresenze = { a1: Object.fromEntries( giocatori.map((g, i) => [g.id, i % 2 === 0 ? ("presente" as const) : ("assente" as const)]), ), }; -const percentuale = trova(obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }), "o1").valore; +const percentuale = trova( + obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }, OGGI_AGOSTO), + "o1", +).valore; assert.ok(percentuale > 40 && percentuale < 60, `metà rosa presente ≈ 50%, era ${percentuale}`); // Il ritardo conta come presenza, il "forse" no. const conRitardo: MappaPresenze = { a1: { g1: "ritardo", g2: "forse" } }; assert.equal( - trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o1").valore, + trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }, OGGI_AGOSTO), "o1").valore, Math.round((1 / giocatori.length) * 100), "solo il ritardo conta come presente", ); assert.equal( - trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o2").valore, + trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }, OGGI_AGOSTO), "o2").valore, Math.round((2 / giocatori.length) * 100), "per le risposte anche il forse conta", ); @@ -78,7 +89,7 @@ const fuoriMese: ContestoObiettivi = { presenze: { s1: { g1: "presente" } }, pagelle: [], }; -assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese), "o1").valore, 0); +assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese, OGGI_AGOSTO), "o1").valore, 0); // I compleanni non richiedono risposta. const soloCompleanni: ContestoObiettivi = { @@ -86,7 +97,68 @@ const soloCompleanni: ContestoObiettivi = { presenze: {}, pagelle: [], }; -assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni), "o2").valore, 0); +assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0); + +// --- 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. + const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z"); + const eventoSettembre: ContestoObiettivi = { + eventi: [evento("s2", "2026-09-04", "allenamento")], + presenze: { s2: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])) }, + pagelle: [], + }; + assert.equal( + trova(obiettiviSquadra(giocatori, eventoSettembre, OGGI_SETTEMBRE), "o1").valore, + 100, + "evento di settembre conta se oggi è settembre", + ); + assert.equal( + trova(obiettiviSquadra(giocatori, eventoSettembre, OGGI_AGOSTO), "o1").valore, + 0, + "lo stesso evento non conta se oggi è agosto", + ); + + // Titolo e scadenza seguono il mese corrente, non più una stagione fissa. + const o1Agosto = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_AGOSTO), "o1"); + assert.equal(o1Agosto.titolo, "90% di presenze ad agosto", "elisione 'ad' davanti a vocale"); + assert.equal(o1Agosto.scadenza, "2026-08-31", "scadenza = ultimo giorno del mese"); + + const o1Settembre = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_SETTEMBRE), "o1"); + assert.ok(o1Settembre.titolo.includes("settembre"), "titolo o1 riflette il mese iniettato (settembre)"); + assert.equal(o1Settembre.scadenza, "2026-09-30", "scadenza = ultimo giorno di settembre (30 gg)"); +} + +// --- o6: evento di squadra al mese, si azzera come o1 ------------------------ +{ + const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z"); + const pizzataSettembre: ContestoObiettivi = { + eventi: [evento("p1", "2026-09-12", "evento")], + presenze: {}, + pagelle: [], + }; + assert.equal( + trova(obiettiviSquadra(giocatori, pizzataSettembre, OGGI_SETTEMBRE), "o6").valore, + 1, + "l'evento sociale di settembre conta se oggi è settembre", + ); + assert.equal( + trova(obiettiviSquadra(giocatori, pizzataSettembre, OGGI_AGOSTO), "o6").valore, + 0, + "lo stesso evento non conta se oggi è agosto", + ); + + const allenamentoNelMese: ContestoObiettivi = { + eventi: [evento("al1", "2026-08-12", "allenamento")], + presenze: {}, + pagelle: [], + }; + assert.equal( + trova(obiettiviSquadra(giocatori, allenamentoNelMese, OGGI_AGOSTO), "o6").valore, + 0, + "un allenamento nel mese non è un evento sociale", + ); +} // --- somme sulla rosa -------------------------------------------------------- const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);