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>
18 KiB
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 e di un obiettivo di squadra.
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 ogniGiocatoredentro l'unicauseMemodiuseRosa().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:
- Filtra gli eventi rilevanti con
eventiContanoPresenze()— la stessa funzione che alimenta il conteggio presenze, così le due statistiche non possono divergere:- solo
tipopartitaoallenamento(maieventoocompleanno); - solo eventi a cui il giocatore era convocato.
convocativuoto 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.
- solo
- Tiene solo gli eventi già passati (
e.data < oggi, dentroeventiContanoPresenze()). 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 parametrooggiè iniettabile — di defaultdataOggi()— e i test lo fissano a una data per non dipendere dall'orologio. - Ordina per data crescente (
localeComparesuYYYY-MM-DD). - Riduce applicando
aggiornaSerie(serie, onorato(e))a ogni evento:+1se onorato,0altrimenti. 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
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:
infortunatocongela la serie: l'evento è escluso a monte (filtrato prima diserieSu()), quindi non conta né come presenza né come buco — la serie resta al valore di prima. Diverso dacontaPresenzeGiocatore(), che continua a non contarlo come presenza (stesso criteriopresente/ritardodi 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
tipoconta partite e allenamenti insieme: è così che si ottienestreak.
serieConferme() — conferme entro 24 ore
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
creatoIlvengono saltati e non spezzano la serie. Sono gli eventi costruiti dal client e mai salvati a database — i compleanni dicompleanniEventi()e la bozza dieventoVuoto(). 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:
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:
valore === 0→ «Serie … azzerata: riparti dal prossimo.»prossimo === null→ «Serie leggendaria: sei fuori scala!»manca === 1→ «Manca solo una volta al prossimo traguardo!»valore >= 5→ «Che continuità: ancora N e sali di livello.»- 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 diserieDefs.serieMigliore(g)— quella col valore più alto.Array.sortè stabile, quindi a parità vince la prima definita inserieDefs: con tutto a zero esce sempre "Allenamenti".
Interfaccia (src/components/crapp/SerieCard.tsx)
SerieGriglia— montata insrc/routes/profilo.tsx, sezione "Serie di presenze". Una card per serie: icona (sfondo gradiente sevalore > 0, grigio se a zero), label, descrizione, fiamma col numero, barraBarrae riga di testo"valore/prossimo · messaggio"(il prefissovalore/prossimosparisce 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
traguardiinserieDefs. 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 anchecontaPresenzeGiocatore(), che oggi conta ancorainfortunatocome assenza ai fini statistici. - Non azzerare quando manca la risposta → sempre in quel predicato: distinguere
stato === undefinede restituire la serie invariata invece difalse. Richiede di cambiareserieSu(), 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 ineventiContanoPresenze(), che però è condiviso col conteggio presenze: meglio un filtro dedicato passato aserieSu()che modificarlo lì. - Aggiungere una quarta serie → una voce in
serieDefs(label, descrizione, icona, traguardi,valore), un campo nel tipoGiocatore(crapp-data.ts, più lo zero nel seed), il calcolo inpresenze.tse il collegamento inuseRosa(). La UI non va toccata: griglia e home iterano suserieDefs. - Mostrare il riepilogo in home →
SerieHomeesiste 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_ilper 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.