Files

175 lines
12 KiB
Markdown
Raw Permalink Normal View History

# 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 | 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 (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
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.
`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) |
| 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) |
- **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 eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **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).