# Modulo — Obiettivi di squadra **Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano, copertura test completa (unit + integration) su tutti e 10 gli obiettivi. **File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`) --- ## Obiettivo Mostrare traguardi collettivi (non individuali) che avanzano con il contributo di tutta la rosa — presenze, risposte alle convocazioni, pagelle, risultati di campionato — per motivare comportamenti di squadra oltre alla singola prestazione. --- ## Dati Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` (`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li ordina mettendo i completati in coda e gli altri per progresso decrescente. Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia esplicita. Il problema che _sembrava_ riguardare gli obiettivi era in realtà nelle tabelle collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la cancellazione: risolto a livello database con un trigger (migration `m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi. `obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default `new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese corrente dinamico (vedi sotto). --- ## Obiettivi definiti | id | Obiettivo | Calcolo | Target | Fonte | | ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- | | `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` | | `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). 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" (vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri. --- ## Obiettivi mensili — mese dinamico `o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente (fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo ("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese) seguono di conseguenza. `o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome, `adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a 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` 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 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. --- ## Continuità di squadra — il target 12 è il minimo per un 6vs6 Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo". Dipende da `serieAllenamenti` (vedi [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 fisica. --- ## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna (`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite `partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in `src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il proprio target, nemmeno con più vittorie di quante ne servano. ### Il limite: il parsing del JSON può rompersi in silenzio `result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un errore solo se _sia_ la classifica _sia_ le partite sono vuote insieme, quindi se si rompe solo il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200` con `partite: []`. ### Come è mitigato oggi - **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti anche loro). - La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede. - Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica` (`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server. ### Come fixarlo, se succede 1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare `CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero). 2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare `punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in `partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato. 3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint** stessi o serve autenticazione — non per un semplice cambio di formato dei dati. 4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti restano verdi contro un formato che non esiste più. Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in [Collegamento CSI](collegamento-csi.md). --- ## Copertura test 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, 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, filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico (evento dentro/fuori mese), scadenza dinamica. - **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 della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media arrotondata e conteggio. - **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende `test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e verifica cap e target su dati reali. Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration` (richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).