diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b99148..6463cf8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,22 @@ qui: sta in [ROADMAP.md](ROADMAP.md). ## Versione attuale — agosto 2026 +### Serie di presenze calcolate sui dati reali + +- `serieConsecutiva()` (`src/lib/presenze.ts`) deriva le serie da eventi passati e + `risposte_presenze`: prima erano `0` fisso in `useRosa()` e la sezione «Serie di presenze» + del profilo era di fatto inerte, insieme ai badge e all'obiettivo «Continuità di squadra» + che ne dipendono. +- Migration `m9_risposte_presenze_risposto_il`: nuova colonna `risposto_il` con l'istante + della **prima** risposta, resa immutabile da un trigger (`aggiornato_il` registrava solo + l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento). + Confrontata con `eventi_app.creato_il` sblocca finalmente la serie "Conferme 24h" e i badge + "Risposta lampo" e "Mai un forfait". Il dato non è ricostruibile all'indietro: vale da qui + in avanti (vedi [modules/serie-presenze.md](modules/serie-presenze.md)). +- La barra di progresso di una serie ora misura l'avanzamento fra il traguardo raggiunto e il + successivo: prima usava `valore/prossimo` e tornava indietro a ogni traguardo (2/3 = 67%, + poi 3/6 = 50%). + ### Autenticazione e dashboard amministratore (in produzione) - Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 699ab33..48d0493 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -28,7 +28,7 @@ non in questo file. | Tabella | Scopo | Note | | ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- | | `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. | -| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. | +| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). | diff --git a/docs/modules/serie-presenze.md b/docs/modules/serie-presenze.md index 1f11cd3..6f0044c 100644 --- a/docs/modules/serie-presenze.md +++ b/docs/modules/serie-presenze.md @@ -1,7 +1,10 @@ # Modulo — Serie di presenze -**Stato:** implementato solo lato definizione/UI — **non calcola valori reali** (vedi Limiti noti) -**File principali:** `src/lib/serie.ts`, `src/components/crapp/SerieCard.tsx` +**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` --- @@ -14,47 +17,296 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra]( --- -## Dati +## Le tre serie in sintesi -Non esiste una tabella dedicata: le serie sono campi (`serieAllenamenti`, `seriePartite`, -`serieConferme`) del tipo `Giocatore` assemblato da `useRosa()` (`src/lib/rosa.ts`). +| 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. --- -## Implementazione +## Dati -- `src/lib/serie.ts` — definizione dei 3 tipi di serie (`serieDefs`, con label, descrizione e - 4 traguardi crescenti ciascuna), funzione pura `aggiornaSerie(valore, onorato)` (regola: +1 - se onorato, altrimenti azzeramento **solo di quella serie**), `statoSerie()` (progresso e - messaggio verso il prossimo traguardo), `serieGiocatore()`/`serieMigliore()` (aggregatori - per la UI). -- `src/components/crapp/SerieCard.tsx` — `SerieGriglia` (vista completa nel profilo) e - `SerieHome` (riepilogo compatto in home, solo la serie più alta). -- `src/routes/profilo.tsx` — monta `SerieGriglia` nella sezione "Serie di presenze". +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. + +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. **Scarta il futuro** (`e.data <= oggi`). Gli eventi di oggi contano già: se serve un + confronto diverso, il parametro `oggi` è iniettabile (i test lo fissano a una data). +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` azzera la serie**, esattamente come `assente`. Coerente con il conteggio + presenze, ma è una scelta da rivedere se si vuole "congelare" la serie di chi è fermo per + infortunio. +- **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** (per esempio non azzerare su `infortunato`) → il + predicato dentro `serieConsecutiva()`. Valutare se allineare anche + `contaPresenzeGiocatore()`, che oggi usa lo stesso criterio. +- **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 -**La funzione `aggiornaSerie()` non è invocata da nessun punto del codice.** I tre campi che -alimentano la UI (`serieAllenamenti`, `seriePartite`, `serieConferme`, oltre a `streak`) sono -impostati a `0` fisso in `useRosa()` (`src/lib/rosa.ts`) e nel seed storico di -`crapp-data.ts`. Di conseguenza, con i dati reali della rosa: +**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. -- le card in `SerieGriglia` mostrano sempre progresso 0; -- i badge che dipendono dalle serie ("Sempre in palestra", "Risposta lampo", il segreto "Mai - un forfait") non possono mai sbloccarsi; -- l'obiettivo di squadra "Continuità di squadra" (≥3 allenamenti consecutivi per almeno 12 - giocatori) resta permanentemente a 0/12. +**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha +mai risposto ha serie a 0. -Il modulo è quindi completo lato definizione e UI, ma **funzionalmente inerte**: manca il -collegamento che calcoli le serie da `risposte_presenze` e le derivi per ogni giocatore. +**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. + +**Il fuso è quello del client.** `oggi` nasce da `new Date().toISOString()`, cioè UTC: nelle +prime ore della giornata italiana un evento di oggi può risultare "non ancora passato". --- ## Evoluzioni possibili -- Calcolare le tre serie a partire da `risposte_presenze` (ordinando gli eventi per data e - applicando `aggiornaSerie()` in sequenza), lato client in `useRosa()` o come valore - derivato lato server. -- Una volta corretto, verificare che i badge e l'obiettivo collegati si sblocchino davvero. +- 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. diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 45fc063..abf4dbf 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -487,18 +487,21 @@ export type Database = { aggiornato_il: string; evento_id: string; giocatore_id: string; + risposto_il: string; stato: string; }; Insert: { aggiornato_il?: string; evento_id: string; giocatore_id: string; + risposto_il?: string; stato: string; }; Update: { aggiornato_il?: string; evento_id?: string; giocatore_id?: string; + risposto_il?: string; stato?: string; }; Relationships: []; diff --git a/src/lib/eventi.ts b/src/lib/eventi.ts index a3aa316..94564f7 100644 --- a/src/lib/eventi.ts +++ b/src/lib/eventi.ts @@ -20,6 +20,9 @@ export type Evento = { casa: boolean; /** Le pagelle di questa partita non accettano più voti. */ pagelleChiuse: boolean; + /** Quando l'evento è stato creato: è l'istante della convocazione. + * Assente sugli eventi generati dal client (compleanni, bozze non salvate). */ + creatoIl?: string | undefined; }; export type RigaEvento = { @@ -34,6 +37,7 @@ export type RigaEvento = { campionato: boolean; casa: boolean | null; pagelle_chiuse: boolean; + creato_il?: string; }; /** Conversione riga database -> modello applicativo (riusabile anche lato server). */ @@ -50,11 +54,12 @@ export function daRiga(r: RigaEvento): Evento { campionato: !!r.campionato, casa: r.casa ?? true, pagelleChiuse: !!r.pagelle_chiuse, + creatoIl: r.creato_il, }; } const COLONNE = - "id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse"; + "id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il"; /** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */ export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento"; diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index b89c470..6a51d3c 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import type { Stato } from "./crapp-data"; import type { Evento } from "./eventi"; +import { aggiornaSerie } from "./serie"; export const PRESENZE_KEY = ["risposte-presenze"] as const; /** eventoId -> giocatoreId -> stato */ export type MappaPresenze = Record>; +/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */ +export type MappaTempiRisposta = Record>; + /** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */ function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) { return eventi.filter( @@ -36,22 +40,87 @@ export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): nu return eventiContanoPresenze(eventi, giocatoreId).length; } -async function fetchPresenze(): Promise { +/** + * Serie di presenze consecutive su eventi già passati, in ordine di data: + * ogni presenza (o ritardo) vale +1, qualsiasi altra risposta — o nessuna + * risposta — azzera la serie. Senza `tipo` conta partite e allenamenti insieme. + */ +export function serieConsecutiva( + giocatoreId: string, + eventi: Evento[], + presenze: MappaPresenze, + tipo?: "partita" | "allenamento", + oggi: string = oggiIso(), +): number { + return serieSu(giocatoreId, eventi, oggi, tipo, (e) => { + const stato = presenze[e.id]?.[giocatoreId]; + return stato === "presente" || stato === "ritardo"; + }); +} + +const ORE_24 = 24 * 60 * 60 * 1000; + +/** + * Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione + * (`creatoIl` dell'evento). Gli eventi senza istante di creazione — quelli generati + * dal client, non salvati a database — non spezzano la serie: vengono saltati. + */ +export function serieConferme( + giocatoreId: string, + eventi: Evento[], + tempi: MappaTempiRisposta, + oggi: string = oggiIso(), +): number { + return serieSu( + giocatoreId, + eventi.filter((e) => e.creatoIl), + oggi, + undefined, + (e) => { + const risposto = tempi[e.id]?.[giocatoreId]; + return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24; + }, + ); +} + +function oggiIso() { + return new Date().toISOString().slice(0, 10); +} + +/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */ +function serieSu( + giocatoreId: string, + eventi: Evento[], + oggi: string, + tipo: "partita" | "allenamento" | undefined, + onorato: (e: Evento) => boolean, +): number { + return eventiContanoPresenze(eventi, giocatoreId) + .filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi) + .sort((a, b) => a.data.localeCompare(b.data)) + .reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0); +} + +type LetturaPresenze = { presenze: MappaPresenze; tempi: MappaTempiRisposta }; + +async function fetchPresenze(): Promise { const { data, error } = await supabase .from("risposte_presenze") - .select("evento_id, giocatore_id, stato"); + .select("evento_id, giocatore_id, stato, risposto_il"); if (error) throw error; - const mappa: MappaPresenze = {}; + const presenze: MappaPresenze = {}; + const tempi: MappaTempiRisposta = {}; for (const riga of data ?? []) { - (mappa[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato; + (presenze[riga.evento_id] ??= {})[riga.giocatore_id] = riga.stato as Stato; + (tempi[riga.evento_id] ??= {})[riga.giocatore_id] = riga.risposto_il; } - return mappa; + return { presenze, tempi }; } /** Una lettura per sessione: le risposte cambiano poco durante la navigazione. */ export function useRispostePresenze() { const query = useQuery({ queryKey: PRESENZE_KEY, queryFn: fetchPresenze, staleTime: 5 * 60_000 }); - return { ...query, presenze: query.data ?? {} }; + return { ...query, presenze: query.data?.presenze ?? {}, tempi: query.data?.tempi ?? {} }; } export function usePresenzeEvento(eventoId: string) { @@ -86,13 +155,22 @@ export function useSalvaPresenza() { }, // Scrittura unica + aggiornamento cache locale, nessuna rilettura. onSuccess: (input) => { - queryClient.setQueryData(PRESENZE_KEY, (prec) => { - const mappa: MappaPresenze = { ...(prec ?? {}) }; - const evento = { ...(mappa[input.eventoId] ?? {}) }; - if (input.stato === null) delete evento[input.giocatoreId]; - else evento[input.giocatoreId] = input.stato; - mappa[input.eventoId] = evento; - return mappa; + queryClient.setQueryData(PRESENZE_KEY, (prec) => { + const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) }; + const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) }; + const stati = { ...(presenze[input.eventoId] ?? {}) }; + const istanti = { ...(tempi[input.eventoId] ?? {}) }; + if (input.stato === null) { + delete stati[input.giocatoreId]; + delete istanti[input.giocatoreId]; + } else { + stati[input.giocatoreId] = input.stato; + // Come a database: l'istante è quello della prima risposta, non dei ripensamenti. + istanti[input.giocatoreId] ??= new Date().toISOString(); + } + presenze[input.eventoId] = stati; + tempi[input.eventoId] = istanti; + return { presenze, tempi }; }); }, }); diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 27f2e44..ef8f524 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -9,7 +9,13 @@ import { useTurniPalloni } from "./palloni"; import { useInfortuniERitardi } from "./infortuni"; import { useGiocatoreId } from "./user-store"; import { useEventi } from "./eventi"; -import { contaPresenzeGiocatore, totaliEventiGiocatore, useRispostePresenze } from "./presenze"; +import { + contaPresenzeGiocatore, + serieConferme, + serieConsecutiva, + totaliEventiGiocatore, + useRispostePresenze, +} from "./presenze"; import { obiettiviOrdinati } from "./obiettivi"; import { useCsi } from "./csi"; import { partiteGiocate } from "./csi-core"; @@ -33,7 +39,7 @@ export function useRosa(): Giocatore[] { const { turni } = useTurniPalloni(); const { infortuni, ritardi } = useInfortuniERitardi(); const { eventi } = useEventi(); - const { presenze: mappaPresenze } = useRispostePresenze(); + const { presenze: mappaPresenze, tempi } = useRispostePresenze(); const votiMvp = voti.data ?? []; @@ -54,10 +60,10 @@ export function useRosa(): Giocatore[] { iniziali: iniziali(g.nome, g.cognome), presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze), totaliEventi: totaliEventiGiocatore(g.id, eventi), - streak: 0, - serieAllenamenti: 0, - seriePartite: 0, - serieConferme: 0, + streak: serieConsecutiva(g.id, eventi, mappaPresenze), + serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), + seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), + serieConferme: serieConferme(g.id, eventi, tempi), mvp: mvpVinti[g.id] ?? 0, mediaVoto: medie[g.id]?.media ?? 0, palloni: palloni[g.id] ?? 0, @@ -66,7 +72,7 @@ export function useRosa(): Giocatore[] { infortuni: infortuni[g.id] ?? 0, ritardi: ritardi[g.id] ?? 0, })); - }, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze]); + }, [squadra, votiMvp, pagelle, cacche, turni, infortuni, ritardi, eventi, mappaPresenze, tempi]); } /** Il giocatore selezionato sul dispositivo, con le statistiche complete. */ diff --git a/src/lib/serie.ts b/src/lib/serie.ts index 30c3554..3abcbbd 100644 --- a/src/lib/serie.ts +++ b/src/lib/serie.ts @@ -67,9 +67,13 @@ function messaggioSerie(valore: number, prossimo: number | null, label: string) export function statoSerie(def: SerieDef, g: Giocatore): SerieStato { const valore = def.valore(g); - const prossimo = def.traguardi.find((t) => valore < t) ?? null; + const i = def.traguardi.findIndex((t) => valore < t); + const prossimo = i < 0 ? null : def.traguardi[i]!; const manca = prossimo ? prossimo - valore : 0; - const progresso = prossimo ? Math.min(100, Math.round((valore / prossimo) * 100)) : 100; + // Progresso dentro il livello corrente: fra il traguardo già preso e il prossimo, + // altrimenti la barra tornerebbe indietro ogni volta che se ne raggiunge uno. + const base = i <= 0 ? 0 : def.traguardi[i - 1]!; + const progresso = prossimo ? Math.round(((valore - base) / (prossimo - base)) * 100) : 100; return { def, valore, diff --git a/supabase/migrations/20260904120000_m9_risposte_presenze_risposto_il.sql b/supabase/migrations/20260904120000_m9_risposte_presenze_risposto_il.sql new file mode 100644 index 0000000..03df8f8 --- /dev/null +++ b/supabase/migrations/20260904120000_m9_risposte_presenze_risposto_il.sql @@ -0,0 +1,37 @@ +-- M9 — Istante della prima risposta alla convocazione (serie "Conferme 24h") +-- +-- La serie "Conferme 24h" del modulo Serie di presenze richiede di sapere quanto tempo +-- passa fra la convocazione e la risposta del giocatore. Il primo dato c'è già +-- (`eventi_app.creato_il`), il secondo no: `risposte_presenze.aggiornato_il` registra +-- l'ULTIMA modifica, quindi un giocatore che risponde subito e poi cambia idea dopo una +-- settimana risulterebbe lento. Serve un istante separato, scritto una volta sola. +-- +-- Non è un dato ricostruibile a posteriori: chi risponde prima che questa colonna esista +-- non lascia traccia. Le righe già presenti ereditano `aggiornato_il` come miglior +-- approssimazione disponibile. + +ALTER TABLE public.risposte_presenze + ADD COLUMN risposto_il timestamptz NOT NULL DEFAULT now(); + +UPDATE public.risposte_presenze SET risposto_il = aggiornato_il; + +COMMENT ON COLUMN public.risposte_presenze.risposto_il IS + 'Istante della PRIMA risposta del giocatore per questo evento: non cambia se poi cambia stato. Si confronta con eventi_app.creato_il per la serie "Conferme 24h".'; + +-- Il client fa upsert senza questa colonna, quindi un aggiornamento non la tocca. Il +-- trigger difende comunque il valore originale da qualsiasi altra scrittura. +CREATE OR REPLACE FUNCTION public.blocca_risposto_il() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + NEW.risposto_il := OLD.risposto_il; + RETURN NEW; +END; +$$; + +CREATE TRIGGER risposte_presenze_risposto_il_immutabile + BEFORE UPDATE ON public.risposte_presenze + FOR EACH ROW EXECUTE FUNCTION public.blocca_risposto_il(); diff --git a/test/unit/eventi.test.ts b/test/unit/eventi.test.ts index 2bc3da7..808ccdc 100644 --- a/test/unit/eventi.test.ts +++ b/test/unit/eventi.test.ts @@ -14,6 +14,7 @@ const riga: RigaEvento = { campionato: true, casa: true, pagelle_chiuse: false, + creato_il: "2026-08-20T09:00:00Z", }; // --- daRiga: i NULL del database diventano valori sicuri --------------------- @@ -29,6 +30,7 @@ assert.deepEqual(daRiga(riga), { campionato: true, casa: true, pagelleChiuse: false, + creatoIl: "2026-08-20T09:00:00Z", }); const vuota = daRiga({ diff --git a/test/unit/presenze.test.ts b/test/unit/presenze.test.ts new file mode 100644 index 0000000..11fa60a --- /dev/null +++ b/test/unit/presenze.test.ts @@ -0,0 +1,95 @@ +/** Check delle serie di presenze: `bun test/unit/presenze.test.ts`. */ +import assert from "node:assert/strict"; +import type { Evento } from "@/lib/eventi"; +import { + serieConferme, + serieConsecutiva, + type MappaPresenze, + type MappaTempiRisposta, +} from "@/lib/presenze"; + +const ev = (id: string, tipo: Evento["tipo"], data: string, convocati: string[] = []): Evento => ({ + id, + tipo, + titolo: id, + luogo: "", + data, + ora: "20:00", + note: "", + convocati, + campionato: false, + casa: true, + pagelleChiuse: false, +}); + +const OGGI = "2026-09-04"; + +// Passati in ordine sparso: a1 sì, a2 no, a3 sì, a4 sì. La serie riparte dopo il buco. +const eventi: Evento[] = [ + ev("a3", "allenamento", "2026-08-20"), + ev("a1", "allenamento", "2026-08-06"), + ev("p1", "partita", "2026-08-10"), + ev("a2", "allenamento", "2026-08-13"), + ev("a4", "allenamento", "2026-08-27"), + ev("a5", "allenamento", "2026-09-10"), // futuro: non conta ancora +]; + +const presenze: MappaPresenze = { + a1: { g1: "presente" }, + a2: { g1: "assente" }, + a3: { g1: "ritardo" }, + a4: { g1: "presente" }, + p1: { g1: "presente" }, + a5: { g1: "presente" }, +}; + +assert.equal(serieConsecutiva("g1", eventi, presenze, "allenamento", OGGI), 2, "riparte dal buco"); +assert.equal(serieConsecutiva("g1", eventi, presenze, "partita", OGGI), 1); +assert.equal( + serieConsecutiva("g1", eventi, presenze, undefined, OGGI), + 2, + "senza tipo conta partite e allenamenti insieme, sempre in ordine di data", +); + +// Nessuna risposta vale come buco. +assert.equal(serieConsecutiva("g2", eventi, presenze, "allenamento", OGGI), 0); + +// Chi non è convocato non spezza la serie di nessun altro. +const conConvocati = [...eventi, ev("a6", "allenamento", "2026-08-30", ["g9"])]; +assert.equal(serieConsecutiva("g1", conConvocati, presenze, "allenamento", OGGI), 2); + +// --- serie conferme: risposta entro 24h dalla convocazione -------------------- +const convocati = (id: string, data: string, creatoIl?: string): Evento => ({ + ...ev(id, "allenamento", data), + ...(creatoIl === undefined ? {} : { creatoIl }), +}); + +const conConvocazione: Evento[] = [ + convocati("c1", "2026-08-06", "2026-08-01T10:00:00Z"), + convocati("c2", "2026-08-13", "2026-08-08T10:00:00Z"), + convocati("c3", "2026-08-20", "2026-08-15T10:00:00Z"), + convocati("c4", "2026-08-27"), // evento generato dal client: nessuna convocazione tracciata + convocati("c5", "2026-09-10", "2026-09-05T10:00:00Z"), // futuro +]; + +const tempi: MappaTempiRisposta = { + c1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo + c2: { g1: "2026-08-10T10:00:00Z" }, // due giorni dopo: buco + c3: { g1: "2026-08-16T09:59:00Z" }, // appena dentro le 24h + c5: { g1: "2026-09-05T10:30:00Z" }, +}; + +assert.equal(serieConferme("g1", conConvocazione, tempi, OGGI), 1, "il ritardo su c2 azzera"); +assert.equal( + serieConferme("g1", conConvocazione.slice(0, 1), tempi, OGGI), + 1, + "una risposta rapida vale 1", +); +assert.equal( + serieConferme("g1", [conConvocazione[0]!, conConvocazione[3]!, conConvocazione[2]!], tempi, OGGI), + 2, + "un evento senza istante di convocazione viene saltato, non spezza la serie", +); +assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero"); + +console.log("presenze: ok"); diff --git a/test/unit/serie.test.ts b/test/unit/serie.test.ts index 521c375..edf476e 100644 --- a/test/unit/serie.test.ts +++ b/test/unit/serie.test.ts @@ -28,7 +28,14 @@ assert.equal(aDue.messaggio, "Manca solo una volta al prossimo traguardo!"); const sulTraguardo = statoSerie(allenamenti, g({ serieAllenamenti: 3 })); assert.equal(sulTraguardo.prossimo, 6, "raggiunto un traguardo si punta al successivo"); -assert.equal(sulTraguardo.progresso, 50); +assert.equal(sulTraguardo.progresso, 0, "nuovo livello: la barra riparte dal traguardo preso"); + +// La barra non torna mai indietro dentro lo stesso livello. +const dentroIlLivello = [3, 4, 5].map((v) => statoSerie(allenamenti, g({ serieAllenamenti: v }))); +assert.deepEqual( + dentroIlLivello.map((s) => s.progresso), + [0, 33, 67], +); const veterano = statoSerie(allenamenti, g({ serieAllenamenti: 7 })); assert.equal(veterano.messaggio, "Che continuità: ancora 3 e sali di livello.");