Files
CRAPP/docs/modules/serie-presenze.md
T
davideandClaude Sonnet 5 7ef7eef963 Calcola "oggi" nel fuso di Roma ovunque, invece di UTC o del fuso del processo.
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>
2026-09-07 09:47:02 +02:00

18 KiB
Raw Blame History

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 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

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

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:

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 homeSerieHome 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 DatetoISOString(): 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.