diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9115080..958d218 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,9 @@ versioni `0.y.z` erano pre-release. ### Aggiunto +- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che + spiega tutto: come si calcola, cosa conta e cosa no, periodo di validità, fonte dei dati, un esempio. Nessuna + migration. - **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration @@ -22,6 +25,8 @@ versioni `0.y.z` erano pre-release. ### Modificato +- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se + non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Nessuna migration. - **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 diff --git a/docs/modules/obiettivi-squadra.md b/docs/modules/obiettivi-squadra.md index 4306ac3..a84df8c 100644 --- a/docs/modules/obiettivi-squadra.md +++ b/docs/modules/obiettivi-squadra.md @@ -39,7 +39,7 @@ corrente dinamico (vedi sotto). | id | Obiettivo | Calcolo | Target | Fonte | | ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- | -| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` | +| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (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` | @@ -51,7 +51,12 @@ corrente dinamico (vedi sotto). | `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 +primo obiettivo non completato). +Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei +badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i +dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in +`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo +`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica 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" @@ -75,8 +80,9 @@ 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ò +conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i +convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione +`percentualePresenze()`. 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 @@ -156,14 +162,22 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti da un backend reale, non solo funzione pura con contesto costruito a mano). -| Obiettivi | Unit test | Integration test | -| ---------- | ----------------------------- | -------------------------------------------------------- | -| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | -| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | +| Obiettivi | Unit test | Integration test | +| ---------- | ----------------------------- | -------------------------------------------------------------------------------- | +| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) | +| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) | +| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) | +- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti + contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa, + confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia + del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test + diventa rosso. +- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi, + «Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato + in corso/completato. - **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/ `risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi, diff --git a/src/components/crapp/ObiettivoDrawer.tsx b/src/components/crapp/ObiettivoDrawer.tsx new file mode 100644 index 0000000..2662f1b --- /dev/null +++ b/src/components/crapp/ObiettivoDrawer.tsx @@ -0,0 +1,142 @@ +import { useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { formatData } from "@/lib/crapp-data"; +import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi"; +import { Barra } from "@/components/motion/Barra"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; + +function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) { + return ( +
+

+ {titolo} +

+ +
+ ); +} + +function Blocco({ titolo, testo }: { titolo: string; testo: string }) { + return ( +
+

+ {titolo} +

+

{testo}

+
+ ); +} + +/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */ +export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) { + const pct = progressoObiettivo(o); + const fatto = pct >= 100; + const d = o.dettaglio; + return ( +
+
+ + {o.emoji} + +
+ {o.titolo} + + {o.descrizione} + +
+
+ +
+
+

+ Stato attuale +

+ + {fatto ? "Completato" : "In corso"} + +
+

+ {o.valore} su {o.target}{" "} + {o.unita} +

+ +

+ {pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} +

+

{microcopyObiettivo(o)}

+
+ + + + {d.perse ? : null} + + + + + +

+ {o.impatto} +

+
+ ); +} + +/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */ +export function ObiettivoDrawer({ + obiettivo, + children, +}: { + obiettivo: ObiettivoSquadra; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + return ( + + {children} + + + {obiettivo.titolo} + {obiettivo.descrizione} + + + + + + + + + + ); +} diff --git a/src/lib/obiettivi.ts b/src/lib/obiettivi.ts index 286b5d7..7c9064f 100644 --- a/src/lib/obiettivi.ts +++ b/src/lib/obiettivi.ts @@ -14,6 +14,25 @@ export type ObiettivoSquadra = { emoji: string; /** Frase breve che spiega come ogni giocatore può spostare l'ago. */ impatto: string; + /** Spiegazione completa mostrata nella card di dettaglio: nessun punto oscuro. */ + dettaglio: DettaglioObiettivo; +}; + +export type DettaglioObiettivo = { + /** Cosa misura l'obiettivo e come si arriva al numero mostrato, in parole semplici. */ + comeSiCalcola: string; + /** Cosa fa salire il numero. */ + conta: string[]; + /** Cosa è una presenza persa: occupa un posto ma lo lascia vuoto, quindi abbassa la percentuale. */ + perse?: string[]; + /** Cosa resta fuori dal calcolo: non sale e non scende. */ + nonConta: string[]; + /** Quando l'obiettivo parte, quando si azzera o scade. */ + periodo: string; + /** Da dove arrivano i dati e quando si aggiornano. */ + fonte: string; + /** Esempio concreto con numeri. */ + esempio: string; }; export type ContestoObiettivi = { @@ -67,28 +86,21 @@ function fineMese(oggi: Date): string { 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"), - ); - if (delMese.length === 0 || rosaSize === 0) return 0; - const posti = delMese.length * rosaSize; - const presenti = delMese.reduce((s, e) => { - const risposte = ctx.presenze[e.id] ?? {}; - return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length; - }, 0); - 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. + * Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da + * `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non + * è convocato non occupa un posto, quindi la sua assenza non pesa. */ -function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) { +function percentualePresenze( + ctx: ContestoObiettivi, + rosa: Giocatore[], + filtro: (e: Evento) => boolean = () => true, +) { let posti = 0; let presenti = 0; for (const e of ctx.eventi) { if (e.tipo !== "partita" && e.tipo !== "allenamento") continue; + if (!filtro(e)) 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] ?? {}; @@ -126,12 +138,35 @@ export function obiettiviSquadra( id: "o1", titolo: `90% di presenze ${aMese(oggi)}`, descrizione: "Media presenze su partite e allenamenti del mese", - valore: percentualePresenzeMese(ctx, rosa.length, mese), + valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)), target: 90, unita: "%", scadenza: fineMese(oggi), emoji: "📣", impatto: "Ogni sì in più alza la media di tutta la squadra.", + dettaglio: { + comeSiCalcola: + "Si guardano solo le partite e gli allenamenti del mese in corso. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi del mese.", + conta: [ + "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento del mese.", + "Vale anche per gli eventi futuri del mese: appena confermi, la percentuale sale.", + ], + perse: [ + "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.", + "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.", + ], + nonConta: [ + "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.", + "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.", + "Gli eventi degli altri mesi.", + ], + periodo: + "Solo il mese in corso: si azzera il primo giorno di ogni mese e si chiude l'ultimo giorno del mese. Il mese dopo riparte da zero, con il suo titolo e la sua scadenza.", + fonte: + "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.", + esempio: + "Ad agosto ci sono 2 allenamenti. Il primo ha 12 convocati e 10 rispondono «presente»: 10 posti pieni su 12. Il secondo ha 10 convocati e 9 «presenti»: 9 su 10. In tutto 19 posti pieni su 22, cioè 86%. I giocatori non convocati non sono nel conto: per arrivare al 90% servono almeno 20 posti pieni su 22.", + }, }, { id: "o2", @@ -143,16 +178,55 @@ export function obiettiviSquadra( scadenza: fineMese(oggi), emoji: "⚡", impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.", + dettaglio: { + comeSiCalcola: + "Per ogni evento in calendario ci sono tanti posti quanti sono i giocatori in rosa. Si contano le risposte date, di qualsiasi tipo, e si divide per tutti i posti.", + conta: [ + "Qualsiasi risposta: presente, in ritardo o assente. Conta aver risposto, non cosa si è risposto.", + "Partite, allenamenti ed eventi di squadra.", + ], + nonConta: [ + "I compleanni: non richiedono risposta.", + "Un evento a cui non hai ancora risposto.", + ], + periodo: + "Considera tutti gli eventi in programma, non solo quelli del mese. La scadenza indicata è la fine del mese corrente.", + fonte: "Eventi del calendario e risposte dei giocatori. Si aggiorna a ogni risposta.", + esempio: + "Con 14 giocatori e 10 eventi i posti sono 140. Con 126 risposte date, siamo al 90%.", + }, }, { id: "o7", titolo: "Presenze collettive", descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione", - valore: percentualePresenzeCollettive(ctx, rosa), + valore: percentualePresenze(ctx, rosa), target: 90, unita: "%", emoji: "🤝", impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.", + dettaglio: { + comeSiCalcola: + "Si guardano tutte le partite e tutti gli allenamenti della stagione, passati e futuri. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi insieme.", + conta: [ + "«Presente» o «in ritardo» di un convocato a una partita o a un allenamento.", + "Anche le conferme per gli eventi futuri: appena confermi, la percentuale sale.", + ], + perse: [ + "«Assente» di un convocato: è una presenza persa, il posto resta vuoto.", + "«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.", + ], + nonConta: [ + "Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.", + "Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.", + ], + periodo: + "Tutta la stagione: non si azzera mai e non ha scadenza. Conta tutto ciò che c'è in calendario, quindi un evento cancellato smette di contare e uno nuovo aggiunge i suoi posti.", + fonte: + "Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.", + esempio: + "In stagione ci sono 3 eventi: un allenamento con 12 convocati e 10 presenti, uno con 10 convocati e 9 presenti, una partita con 14 convocati e 13 presenti. Posti pieni 32 su 36, cioè 89%: per il 90% manca una presenza in più. La differenza con quello del mese è solo il periodo: qui contano tutti gli eventi insieme, senza azzerarsi.", + }, }, { id: "o12", @@ -163,6 +237,19 @@ export function obiettiviSquadra( unita: "di media", emoji: "📝", impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.", + dettaglio: { + comeSiCalcola: + "Media di tutti i voti dati dai giocatori nelle pagelle dopo le partite, arrotondata a una cifra decimale.", + conta: ["Ogni voto dato in una pagella, a qualsiasi compagno."], + nonConta: [ + "Gli autovoti: non si può votare se stessi.", + "Le pagelle non compilate: non pesano sulla media.", + ], + periodo: "Tutta la stagione. Non si azzera e non ha scadenza.", + fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.", + esempio: + "Se i voti dati finora sono 7, 8 e 7, la media è 7.3: mancano 0.2 per arrivare a 7.5.", + }, }, { id: "o13", @@ -173,6 +260,16 @@ export function obiettiviSquadra( unita: "voti", emoji: "🗳️", impatto: "Vota i compagni a fine partita: bastano due minuti.", + dettaglio: { + comeSiCalcola: + "Si contano tutti i voti dati nelle pagelle durante la stagione. Un voto è un giocatore che valuta un compagno dopo una partita.", + conta: ["Ogni singolo voto dato a un compagno, non ogni pagella intera."], + nonConta: ["Le pagelle non compilate.", "Gli autovoti, che non sono ammessi."], + periodo: "Tutta la stagione. Non si azzera e non ha scadenza.", + fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.", + esempio: + "Se dopo una partita 10 giocatori votano 9 compagni ciascuno, la squadra aggiunge 90 voti.", + }, }, { id: "o11", @@ -183,6 +280,21 @@ export function obiettiviSquadra( unita: "giocatori", emoji: "🔗", impatto: "Tieni viva la tua serie e sblocchi anche questo.", + dettaglio: { + comeSiCalcola: + "Si conta quanti giocatori hanno una serie di almeno 3 allenamenti consecutivi senza saltarne nemmeno uno.", + conta: ["Un giocatore con 3 o più allenamenti di fila in cui era presente o in ritardo."], + nonConta: [ + "Chi ha saltato un allenamento: la serie riparte da zero.", + "Un allenamento passato senza risposta vale come assenza e azzera la serie.", + "Le partite: la serie riguarda solo gli allenamenti.", + "Un allenamento saltato per infortunio non la azzera: la serie resta ferma al valore di prima.", + ], + periodo: "Si aggiorna di continuo sugli allenamenti passati. Non ha scadenza.", + fonte: "Presenze agli allenamenti. Vedi anche la serie personale nel tuo profilo.", + esempio: + "Il target è 12 perché è il numero minimo per un allenamento 6 contro 6. Con 9 giocatori in serie ne mancano 3.", + }, }, { id: "o3", @@ -193,6 +305,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🎉", impatto: "Una prestazione di gruppo e il primo passo è fatto.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con la prima vittoria.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o4", @@ -203,6 +331,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🔥", impatto: "Ogni vittoria ci avvicina ai playoff.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 5 vittorie.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o5", @@ -213,6 +357,22 @@ export function obiettiviSquadra( unita: "vittorie", emoji: "🏆", impatto: "L'obiettivo grande: serve tutta la stagione insieme.", + dettaglio: { + comeSiCalcola: + "Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 10 vittorie.", + conta: [ + "Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.", + ], + nonConta: [ + "Le sconfitte e le partite non ancora giocate.", + "Amichevoli e allenamenti.", + "Le vittorie oltre il target: il contatore si ferma al traguardo.", + ], + periodo: "Tutta la stagione di campionato. Non si azzera.", + fonte: + "Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.", + esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.", + }, }, { id: "o6", @@ -229,6 +389,24 @@ export function obiettiviSquadra( unita: "eventi", emoji: "🍕", impatto: "Il gruppo si costruisce anche fuori dal campo.", + dettaglio: { + comeSiCalcola: + "Si conta quanti eventi di squadra del mese corrente sono già iniziati. Basta uno per completare l'obiettivo.", + conta: [ + "Pizzate, cene e uscite fuori dal campo inserite nel calendario come «evento».", + "Solo quando l'ora dell'evento è arrivata. Se manca l'ora vale la mezzanotte.", + ], + nonConta: [ + "Un evento ancora nel futuro: creato oggi per la settimana prossima non conta ancora.", + "Partite e allenamenti.", + "Eventi di altri mesi.", + ], + periodo: + "Si azzera il primo giorno di ogni mese. Il passaggio a completato compare al primo aggiornamento della pagina dopo l'ora dell'evento.", + fonte: "Eventi del calendario.", + esempio: + "Una pizzata fissata il 20 alle 20:30: fino a quell'ora l'obiettivo resta 0/1, dopo diventa 1/1.", + }, }, ]; } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2c72149..6506de1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -9,6 +9,7 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; import { Barra } from "@/components/motion/Barra"; import { Numero } from "@/components/motion/Numero"; +import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useEventi, type Evento } from "@/lib/eventi"; import { useRispostePresenze } from "@/lib/presenze"; @@ -237,20 +238,32 @@ function Index() { } > {obiettivo ? ( - -
- {obiettivo.emoji} {obiettivo.titolo} -
- -

- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. -

-

- {microcopyObiettivo(obiettivo)} -

-

{obiettivo.impatto}

-
+ + + ) : null} )} diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 13591a6..f4deba7 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { Cake, ChevronDown, Crown, Trophy } from "lucide-react"; +import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, StatTile } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { Avatar } from "@/components/crapp/Avatar"; import { formatData, inizialiDa } from "@/lib/crapp-data"; +import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useRosa, @@ -432,38 +433,47 @@ function Squadra() { const pct = progressoObiettivo(o); const fatto = pct >= 100; return ( - = 90 ? "ring-accent/40" : "ring-transparent", - )} - > -
- {o.emoji} -
-

{o.titolo}

-

{o.descrizione}

-
- + +
- -

- {o.valore}/{o.target} {o.unita} · {pct}% - {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} -

-

{microcopyObiettivo(o)}

-

{o.impatto}

+
+ {o.emoji} +
+

{o.titolo}

+

{o.descrizione}

+
+ + {fatto ? "Completato" : `${pct}%`} + +
+ +

+ {o.valore}/{o.target} {o.unita} · {pct}% + {o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""} +

+

+ {microcopyObiettivo(o)} +

+

{o.impatto}

+

+ Come funziona +

+ +
); })} diff --git a/test/integration/obiettivi.test.ts b/test/integration/obiettivi.test.ts index 3b14cdb..52d833c 100644 --- a/test/integration/obiettivi.test.ts +++ b/test/integration/obiettivi.test.ts @@ -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; diff --git a/test/unit/notifiche-smart.test.ts b/test/unit/notifiche-smart.test.ts index 72a3245..f84f8fb 100644 --- a/test/unit/notifiche-smart.test.ts +++ b/test/unit/notifiche-smart.test.ts @@ -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")), diff --git a/test/unit/obiettivi-dettaglio.test.ts b/test/unit/obiettivi-dettaglio.test.ts new file mode 100644 index 0000000..9e7f6d6 --- /dev/null +++ b/test/unit/obiettivi-dettaglio.test.ts @@ -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, "&")), "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"); diff --git a/test/unit/obiettivi.test.ts b/test/unit/obiettivi.test.ts index 7f68d02..77da3cc 100644 --- a/test/unit/obiettivi.test.ts +++ b/test/unit/obiettivi.test.ts @@ -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");