serieConsecutiva()/serieConferme() calcolavano oggi con new Date().toISOString() (sempre UTC), mentre contaPresenzeGiocatore() usava dataOggi() con i getter locali di Date (corretti solo se il processo gira già in fuso italiano — falso su un server SSR in UTC). Le due statistiche potevano non essere d'accordo su cosa fosse "oggi" nelle prime ore della giornata italiana. dataOggi() ora usa Intl.DateTimeFormat con timeZone: "Europe/Rome": il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un offset scritto a mano. Le funzioni di serie in presenze.ts usano lo stesso dataOggi() invece di un oggiIso() locale, così tutte le statistiche restano coerenti fra loro. Aggiunti test che dimostrano il fix con istanti reali a cavallo di mezzanotte sia in CET che in CEST, per provare che lo scarto segue davvero il fuso e non un offset fisso. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
337 lines
18 KiB
Markdown
337 lines
18 KiB
Markdown
# Modulo — Serie di presenze
|
||
|
||
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||
`src/components/crapp/SerieCard.tsx`
|
||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`)
|
||
|
||
---
|
||
|
||
## Obiettivo
|
||
|
||
Motivare la costanza dei giocatori mostrando "serie" (streak) di comportamenti positivi
|
||
consecutivi — presenza agli allenamenti, presenza alle partite, risposta entro 24 ore alla
|
||
convocazione — con traguardi progressivi, sullo stile delle app fitness. È anche uno dei
|
||
requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](obiettivi-squadra.md).
|
||
|
||
---
|
||
|
||
## Le tre serie in sintesi
|
||
|
||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||
|
||
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||
`src/routes/profilo.tsx`.
|
||
|
||
Le serie sono **indipendenti**: un buco agli allenamenti non tocca partite e conferme. È la
|
||
regola scritta in `aggiornaSerie()` e va mantenuta se si aggiungono altre serie.
|
||
|
||
---
|
||
|
||
## Dati
|
||
|
||
Non esiste una tabella delle serie e non c'è nessun contatore salvato: **le serie sono
|
||
ricalcolate da zero a ogni render**, partendo dagli eventi e dalle risposte già in cache
|
||
React Query. Nessuna query aggiuntiva, nessuna migration da rifare quando si cambia una
|
||
regola, nessun rischio di contatori disallineati dalla realtà.
|
||
|
||
Conseguenza pratica: se domani si inseriscono le presenze di eventi passati (import,
|
||
backfill, correzione a mano), le serie si aggiornano da sole al caricamento successivo.
|
||
|
||
### Tabelle lette
|
||
|
||
| Tabella | Colonne usate | A cosa servono |
|
||
| ------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||
| `eventi_app` | `id`, `tipo`, `data`, `convocati`, `creato_il` | Quali impegni contano, in che ordine, e quando è partita la convocazione |
|
||
| `risposte_presenze` | `evento_id`, `giocatore_id`, `stato`, `risposto_il` | Se l'impegno è stato onorato e quanto in fretta è arrivata la risposta |
|
||
|
||
`risposto_il` (migration `m9`) è l'istante della **prima** risposta del giocatore per quell'
|
||
evento. Un trigger (`risposte_presenze_risposto_il_immutabile`) lo blocca su qualsiasi
|
||
UPDATE: senza, un giocatore che risponde subito e cambia idea una settimana dopo risulterebbe
|
||
lento. `aggiornato_il` continua a registrare l'ultima modifica ed è un'altra cosa: non usarlo
|
||
per le conferme.
|
||
|
||
Le due colonne si confondono facilmente, e sbagliarle non rompe niente di visibile: la serie
|
||
comincia solo a raccontare il falso. Per questo il confine è verificato in
|
||
`test/integration/scritture.test.ts` («la risposta di presenza si aggiorna senza far ripartire
|
||
il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto_il` e controlla
|
||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||
|
||
| Colonna | Cosa registra | Chi la usa |
|
||
| --------------- | --------------------- | ----------------------- |
|
||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||
|
||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||
|
||
---
|
||
|
||
## Flusso completo
|
||
|
||
```
|
||
eventi_app ─┐
|
||
├─► useEventi() ─┐
|
||
risposte_ │ (src/lib/eventi.ts) │
|
||
presenze ─┘ ├─► useRosa() ─► Giocatore.serie* ─┐
|
||
useRispostePresenze() ─┘ (rosa.ts) │
|
||
(presenze.ts) │
|
||
▼
|
||
serieGiocatore() / serieMigliore()
|
||
(serie.ts, applica serieDefs)
|
||
│
|
||
┌────────────────────────────────┼──────────────┐
|
||
▼ ▼ ▼
|
||
SerieGriglia SerieHome badges.ts
|
||
(profilo) (home) obiettivi.ts
|
||
```
|
||
|
||
Chi calcola cosa:
|
||
|
||
- **`src/lib/presenze.ts`** — i tre numeri, dai dati grezzi.
|
||
- **`src/lib/rosa.ts`** — li attacca a ogni `Giocatore` dentro l'unica `useMemo` di `useRosa()`.
|
||
- **`src/lib/serie.ts`** — definizioni, traguardi, progresso e microcopy: da un numero a uno stato mostrabile.
|
||
- **`src/components/crapp/SerieCard.tsx`** — la resa a schermo.
|
||
|
||
---
|
||
|
||
## Il calcolo (`src/lib/presenze.ts`)
|
||
|
||
Tutte le serie passano dalla stessa funzione privata `serieSu()`, che fa quattro cose in
|
||
ordine:
|
||
|
||
1. **Filtra gli eventi rilevanti** con `eventiContanoPresenze()` — la stessa funzione che
|
||
alimenta il conteggio presenze, così le due statistiche non possono divergere:
|
||
- solo `tipo` `partita` o `allenamento` (mai `evento` o `compleanno`);
|
||
- solo eventi a cui il giocatore era convocato. **`convocati` vuoto significa "tutta la
|
||
rosa"**, non "nessuno": chi non è nell'elenco di una convocazione ristretta non vede
|
||
quell'evento e la sua serie non si spezza.
|
||
2. **Tiene solo gli eventi già passati** (`e.data < oggi`, dentro `eventiContanoPresenze()`).
|
||
Il confronto è **stretto**: l'evento di oggi non conta ancora, perché nessuno ha potuto
|
||
presentarsi e conterebbe come assenza, azzerando la serie di tutta la squadra la mattina
|
||
della partita. Entra in gioco dal giorno dopo. Il parametro `oggi` è iniettabile — di
|
||
default `dataOggi()` — e i test lo fissano a una data per non dipendere dall'orologio.
|
||
3. **Ordina per data crescente** (`localeCompare` su `YYYY-MM-DD`).
|
||
4. **Riduce** applicando `aggiornaSerie(serie, onorato(e))` a ogni evento: `+1` se onorato,
|
||
`0` altrimenti. La serie finale è quella che risulta **dopo l'ultimo evento passato**.
|
||
|
||
Quel che cambia fra le serie è solo il predicato `onorato`.
|
||
|
||
### `serieConsecutiva()` — allenamenti, partite, `streak`
|
||
|
||
```ts
|
||
serieConsecutiva(giocatoreId, eventi, presenze, tipo?, oggi?)
|
||
```
|
||
|
||
Onorato = lo stato salvato è `presente` **o** `ritardo`. Gli stati possibili sono
|
||
`presente | assente | forse | ritardo | infortunato` (`src/lib/crapp-data.ts`).
|
||
|
||
Conseguenze da conoscere prima di cambiare qualcosa:
|
||
|
||
- **`infortunato` congela la serie**: l'evento è escluso a monte (filtrato prima di
|
||
`serieSu()`), quindi non conta né come presenza né come buco — la serie resta al valore
|
||
di prima. Diverso da `contaPresenzeGiocatore()`, che continua a non contarlo come
|
||
presenza (stesso criterio `presente`/`ritardo` di prima, invariato).
|
||
- **Nessuna risposta azzera la serie.** Un evento passato per cui il giocatore non ha mai
|
||
toccato l'app equivale a un'assenza. È voluto (la serie premia anche il rispondere), ma
|
||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||
|
||
### `serieConferme()` — conferme entro 24 ore
|
||
|
||
```ts
|
||
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||
```
|
||
|
||
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||
|
||
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||
sulla reattività, non sulla presenza.
|
||
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||
l'istante giusto da confrontare.
|
||
|
||
### Lettura e cache
|
||
|
||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||
|
||
```ts
|
||
presenze: { [eventoId]: { [giocatoreId]: Stato } }
|
||
tempi: { [eventoId]: { [giocatoreId]: string /* ISO */ } }
|
||
```
|
||
|
||
Entrambe vivono nella stessa entry di React Query (`PRESENZE_KEY`, `staleTime` 5 minuti) e
|
||
`useRispostePresenze()` le espone come `presenze` e `tempi`.
|
||
|
||
`useSalvaPresenza()` non rilegge dopo la scrittura: aggiorna la cache a mano e deve tenere
|
||
allineate **entrambe** le mappe. Sull'`upsert` la colonna `risposto_il` non viene inviata —
|
||
è quello che la lascia intatta lato database sugli aggiornamenti — e la cache locale imita
|
||
la stessa regola con `istanti[giocatoreId] ??= new Date().toISOString()`: si valorizza solo
|
||
se manca. Chi tocca quella mutation deve preservare questi due dettagli, altrimenti ogni
|
||
ripensamento farebbe ripartire il cronometro delle conferme.
|
||
|
||
---
|
||
|
||
## Da numero a card (`src/lib/serie.ts`)
|
||
|
||
`serieDefs` è l'unica fonte di verità della UI: label, descrizione, icona, traguardi e la
|
||
funzione `valore(g)` che pesca il campo giusto dal `Giocatore`.
|
||
|
||
`statoSerie(def, g)` produce quello che serve a disegnare una card:
|
||
|
||
| Campo | Come si ricava |
|
||
| ----------- | --------------------------------------------------------------------------- |
|
||
| `valore` | `def.valore(g)` |
|
||
| `prossimo` | primo traguardo **strettamente maggiore** del valore; `null` oltre l'ultimo |
|
||
| `manca` | `prossimo - valore` (`0` se fuori scala) |
|
||
| `progresso` | percentuale **dentro il livello corrente**, vedi sotto |
|
||
| `messaggio` | microcopy, vedi sotto |
|
||
|
||
### Progresso
|
||
|
||
```
|
||
progresso = round((valore - traguardoPrecedente) / (prossimo - traguardoPrecedente) * 100)
|
||
```
|
||
|
||
La base è il traguardo già raggiunto, non zero. Con la vecchia formula (`valore / prossimo`)
|
||
la barra **tornava indietro** ogni volta che se ne raggiungeva uno: a 2 allenamenti segnava
|
||
67%, al terzo scendeva al 50%. Ora ogni traguardo apre un livello nuovo che riparte da 0% e
|
||
sale fino a 100%, che si tocca solo restando fuori scala (`prossimo === null`).
|
||
|
||
Esempio con i traguardi degli allenamenti (3, 6, 10, 15):
|
||
|
||
| Valore | Prossimo | Base | Progresso |
|
||
| ------ | -------- | ---- | --------- |
|
||
| 0 | 3 | 0 | 0% |
|
||
| 2 | 3 | 0 | 67% |
|
||
| 3 | 6 | 3 | 0% |
|
||
| 5 | 6 | 3 | 67% |
|
||
| 15+ | — | — | 100% |
|
||
|
||
### Messaggi
|
||
|
||
`messaggioSerie()` valuta in quest'ordine, prima corrispondenza vince:
|
||
|
||
1. `valore === 0` → «Serie … azzerata: riparti dal prossimo.»
|
||
2. `prossimo === null` → «Serie leggendaria: sei fuori scala!»
|
||
3. `manca === 1` → «Manca solo una volta al prossimo traguardo!»
|
||
4. `valore >= 5` → «Che continuità: ancora N e sali di livello.»
|
||
5. altrimenti → «Bella partenza: N al prossimo traguardo.»
|
||
|
||
Nota: il caso 1 scatta anche per chi non ha **mai** iniziato, e dice "azzerata". Se dà
|
||
fastidio, va distinto lì — il calcolo non sa differenziare "mai partito" da "appena rotto".
|
||
|
||
### Aggregatori
|
||
|
||
- `serieGiocatore(g)` — tutte le serie nell'ordine di `serieDefs`.
|
||
- `serieMigliore(g)` — quella col valore più alto. `Array.sort` è stabile, quindi **a parità
|
||
vince la prima definita in `serieDefs`**: con tutto a zero esce sempre "Allenamenti".
|
||
|
||
---
|
||
|
||
## Interfaccia (`src/components/crapp/SerieCard.tsx`)
|
||
|
||
- **`SerieGriglia`** — montata in `src/routes/profilo.tsx`, sezione "Serie di presenze". Una
|
||
card per serie: icona (sfondo gradiente se `valore > 0`, grigio se a zero), label,
|
||
descrizione, fiamma col numero, barra `Barra` e riga di testo `"valore/prossimo · messaggio"`
|
||
(il prefisso `valore/prossimo` sparisce fuori scala).
|
||
- **`SerieHome`** — riepilogo compatto: la serie migliore in evidenza più i tre numeri in
|
||
griglia. Attualmente **non è montata in nessuna route**: è pronta ma non usata.
|
||
|
||
---
|
||
|
||
## Chi dipende dalle serie
|
||
|
||
Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
||
|
||
| Dove | Cosa | Soglie |
|
||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||
|
||
---
|
||
|
||
## Costo
|
||
|
||
`useRosa()` ricalcola quattro serie per ogni giocatore attivo a ogni invalidazione della
|
||
memo, e ogni serie scorre tutti gli eventi: **O(rosa × eventi)** per render memoizzato. Con
|
||
una rosa e un calendario di squadra sono numeri irrisori. Le dipendenze della memo includono
|
||
`eventi`, `mappaPresenze` e `tempi`: se in futuro qualcuna cambiasse identità a ogni render,
|
||
il costo diventerebbe per-render e andrebbe stabilizzata a monte.
|
||
|
||
---
|
||
|
||
## Come modificare
|
||
|
||
- **Cambiare i traguardi di una serie** → l'array `traguardi` in `serieDefs`. Devono restare
|
||
crescenti (un test lo verifica) e non serve altro: progresso e messaggi si adeguano.
|
||
- **Cambiare la regola di presenza** → il predicato dentro `serieConsecutiva()`.
|
||
`infortunato` è già escluso a monte (congela la serie, non la azzera); valutare se
|
||
allineare anche `contaPresenzeGiocatore()`, che oggi conta ancora `infortunato` come
|
||
assenza ai fini statistici.
|
||
- **Non azzerare quando manca la risposta** → sempre in quel predicato: distinguere
|
||
`stato === undefined` e restituire la serie invariata invece di `false`. Richiede di
|
||
cambiare `serieSu()`, che oggi conosce solo "onorato sì/no".
|
||
- **Cambiare la finestra delle conferme** → la costante `ORE_24`.
|
||
- **Contare anche gli eventi extra-campo** (pizzate, `tipo: "evento"`) → il filtro in
|
||
`eventiContanoPresenze()`, che però è condiviso col conteggio presenze: meglio un filtro
|
||
dedicato passato a `serieSu()` che modificarlo lì.
|
||
- **Aggiungere una quarta serie** → una voce in `serieDefs` (label, descrizione, icona,
|
||
traguardi, `valore`), un campo nel tipo `Giocatore` (`crapp-data.ts`, più lo zero nel seed),
|
||
il calcolo in `presenze.ts` e il collegamento in `useRosa()`. La UI non va toccata: griglia
|
||
e home iterano su `serieDefs`.
|
||
- **Mostrare il riepilogo in home** → `SerieHome` esiste già, basta montarla.
|
||
|
||
---
|
||
|
||
## Limiti noti
|
||
|
||
**Le conferme rapide valgono solo da `m9` in avanti.** `risposto_il` non è ricostruibile a
|
||
posteriori: le righe già esistenti al momento della migration hanno ereditato `aggiornato_il`,
|
||
che è l'ultima modifica e non la prima risposta. Sui dati precedenti la serie è quindi
|
||
un'approssimazione ottimistica.
|
||
|
||
**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha
|
||
mai risposto ha serie a 0.
|
||
|
||
**L'ordinamento usa solo `data`, non `ora`.** Due eventi lo stesso giorno vengono processati
|
||
nell'ordine in cui arrivano dalla query (`.order("data")`), quindi non deterministico fra
|
||
loro. Irrilevante finché un buco e una presenza nello stesso giorno danno lo stesso
|
||
risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
||
|
||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||
|
||
---
|
||
|
||
## Evoluzioni possibili
|
||
|
||
- Istante di convocazione esplicito (invio notifica) da usare al posto di `creato_il` per le
|
||
conferme.
|
||
- Distinguere "serie mai iniziata" da "serie interrotta" nel microcopy.
|
||
- Verificare che i badge e l'obiettivo "Continuità di squadra" si sblocchino davvero sui dati
|
||
di stagione.
|