Calcola davvero le serie di presenze e sblocca badge e obiettivo.
I tre contatori (serieAllenamenti, seriePartite, serieConferme) e streak erano zero fisso in useRosa(): la sezione Serie di presenze del profilo mostrava sempre progresso nullo, i badge legati alla costanza erano impossibili da sbloccare e l'obiettivo Continuita di squadra restava a 0/12. serieConsecutiva() e serieConferme() (src/lib/presenze.ts) derivano le serie dagli eventi passati e da risposte_presenze gia in cache, senza query aggiuntive. Migration m9: nuova colonna risposto_il con l'istante della prima risposta, resa immutabile da un trigger, confrontata con eventi_app.creato_il per le conferme entro 24 ore. Serviva perche aggiornato_il registra l'ultima modifica, quindi chi rispondeva subito e cambiava idea dopo risultava lento. Corregge anche la barra di progresso, che misurava valore/prossimo e tornava indietro a ogni traguardo raggiunto (2/3 = 67%, poi 3/6 = 50%). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,22 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
|
|||||||
|
|
||||||
## Versione attuale — agosto 2026
|
## 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)
|
### Autenticazione e dashboard amministratore (in produzione)
|
||||||
|
|
||||||
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
|
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ non in questo file.
|
|||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
|
| `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). |
|
| `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). |
|
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||||
|
|
||||||
|
|||||||
+281
-29
@@ -1,7 +1,10 @@
|
|||||||
# Modulo — Serie di presenze
|
# Modulo — Serie di presenze
|
||||||
|
|
||||||
**Stato:** implementato solo lato definizione/UI — **non calcola valori reali** (vedi Limiti noti)
|
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||||
**File principali:** `src/lib/serie.ts`, `src/components/crapp/SerieCard.tsx`
|
**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`,
|
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||||
`serieConferme`) del tipo `Giocatore` assemblato da `useRosa()` (`src/lib/rosa.ts`).
|
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||||
|
| `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
|
Non esiste una tabella delle serie e non c'è nessun contatore salvato: **le serie sono
|
||||||
4 traguardi crescenti ciascuna), funzione pura `aggiornaSerie(valore, onorato)` (regola: +1
|
ricalcolate da zero a ogni render**, partendo dagli eventi e dalle risposte già in cache
|
||||||
se onorato, altrimenti azzeramento **solo di quella serie**), `statoSerie()` (progresso e
|
React Query. Nessuna query aggiuntiva, nessuna migration da rifare quando si cambia una
|
||||||
messaggio verso il prossimo traguardo), `serieGiocatore()`/`serieMigliore()` (aggregatori
|
regola, nessun rischio di contatori disallineati dalla realtà.
|
||||||
per la UI).
|
|
||||||
- `src/components/crapp/SerieCard.tsx` — `SerieGriglia` (vista completa nel profilo) e
|
Conseguenza pratica: se domani si inseriscono le presenze di eventi passati (import,
|
||||||
`SerieHome` (riepilogo compatto in home, solo la serie più alta).
|
backfill, correzione a mano), le serie si aggiornano da sole al caricamento successivo.
|
||||||
- `src/routes/profilo.tsx` — monta `SerieGriglia` nella sezione "Serie di presenze".
|
|
||||||
|
### 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
|
## Limiti noti
|
||||||
|
|
||||||
**La funzione `aggiornaSerie()` non è invocata da nessun punto del codice.** I tre campi che
|
**Le conferme rapide valgono solo da `m9` in avanti.** `risposto_il` non è ricostruibile a
|
||||||
alimentano la UI (`serieAllenamenti`, `seriePartite`, `serieConferme`, oltre a `streak`) sono
|
posteriori: le righe già esistenti al momento della migration hanno ereditato `aggiornato_il`,
|
||||||
impostati a `0` fisso in `useRosa()` (`src/lib/rosa.ts`) e nel seed storico di
|
che è l'ultima modifica e non la prima risposta. Sui dati precedenti la serie è quindi
|
||||||
`crapp-data.ts`. Di conseguenza, con i dati reali della rosa:
|
un'approssimazione ottimistica.
|
||||||
|
|
||||||
- le card in `SerieGriglia` mostrano sempre progresso 0;
|
**Un evento passato senza risposta azzera la serie**, come un'assenza dichiarata: chi non ha
|
||||||
- i badge che dipendono dalle serie ("Sempre in palestra", "Risposta lampo", il segreto "Mai
|
mai risposto ha serie a 0.
|
||||||
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.
|
|
||||||
|
|
||||||
Il modulo è quindi completo lato definizione e UI, ma **funzionalmente inerte**: manca il
|
**L'ordinamento usa solo `data`, non `ora`.** Due eventi lo stesso giorno vengono processati
|
||||||
collegamento che calcoli le serie da `risposte_presenze` e le derivi per ogni giocatore.
|
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
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Calcolare le tre serie a partire da `risposte_presenze` (ordinando gli eventi per data e
|
- Istante di convocazione esplicito (invio notifica) da usare al posto di `creato_il` per le
|
||||||
applicando `aggiornaSerie()` in sequenza), lato client in `useRosa()` o come valore
|
conferme.
|
||||||
derivato lato server.
|
- Distinguere "serie mai iniziata" da "serie interrotta" nel microcopy.
|
||||||
- Una volta corretto, verificare che i badge e l'obiettivo collegati si sblocchino davvero.
|
- Verificare che i badge e l'obiettivo "Continuità di squadra" si sblocchino davvero sui dati
|
||||||
|
di stagione.
|
||||||
|
|||||||
@@ -487,18 +487,21 @@ export type Database = {
|
|||||||
aggiornato_il: string;
|
aggiornato_il: string;
|
||||||
evento_id: string;
|
evento_id: string;
|
||||||
giocatore_id: string;
|
giocatore_id: string;
|
||||||
|
risposto_il: string;
|
||||||
stato: string;
|
stato: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
aggiornato_il?: string;
|
aggiornato_il?: string;
|
||||||
evento_id: string;
|
evento_id: string;
|
||||||
giocatore_id: string;
|
giocatore_id: string;
|
||||||
|
risposto_il?: string;
|
||||||
stato: string;
|
stato: string;
|
||||||
};
|
};
|
||||||
Update: {
|
Update: {
|
||||||
aggiornato_il?: string;
|
aggiornato_il?: string;
|
||||||
evento_id?: string;
|
evento_id?: string;
|
||||||
giocatore_id?: string;
|
giocatore_id?: string;
|
||||||
|
risposto_il?: string;
|
||||||
stato?: string;
|
stato?: string;
|
||||||
};
|
};
|
||||||
Relationships: [];
|
Relationships: [];
|
||||||
|
|||||||
+6
-1
@@ -20,6 +20,9 @@ export type Evento = {
|
|||||||
casa: boolean;
|
casa: boolean;
|
||||||
/** Le pagelle di questa partita non accettano più voti. */
|
/** Le pagelle di questa partita non accettano più voti. */
|
||||||
pagelleChiuse: boolean;
|
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 = {
|
export type RigaEvento = {
|
||||||
@@ -34,6 +37,7 @@ export type RigaEvento = {
|
|||||||
campionato: boolean;
|
campionato: boolean;
|
||||||
casa: boolean | null;
|
casa: boolean | null;
|
||||||
pagelle_chiuse: boolean;
|
pagelle_chiuse: boolean;
|
||||||
|
creato_il?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||||
@@ -50,11 +54,12 @@ export function daRiga(r: RigaEvento): Evento {
|
|||||||
campionato: !!r.campionato,
|
campionato: !!r.campionato,
|
||||||
casa: r.casa ?? true,
|
casa: r.casa ?? true,
|
||||||
pagelleChiuse: !!r.pagelle_chiuse,
|
pagelleChiuse: !!r.pagelle_chiuse,
|
||||||
|
creatoIl: r.creato_il,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLONNE =
|
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. */
|
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||||
|
|||||||
+91
-13
@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import type { Stato } from "./crapp-data";
|
import type { Stato } from "./crapp-data";
|
||||||
import type { Evento } from "./eventi";
|
import type { Evento } from "./eventi";
|
||||||
|
import { aggiornaSerie } from "./serie";
|
||||||
|
|
||||||
export const PRESENZE_KEY = ["risposte-presenze"] as const;
|
export const PRESENZE_KEY = ["risposte-presenze"] as const;
|
||||||
|
|
||||||
/** eventoId -> giocatoreId -> stato */
|
/** eventoId -> giocatoreId -> stato */
|
||||||
export type MappaPresenze = Record<string, Record<string, Stato>>;
|
export type MappaPresenze = Record<string, Record<string, Stato>>;
|
||||||
|
|
||||||
|
/** eventoId -> giocatoreId -> istante della prima risposta (ISO). */
|
||||||
|
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
|
/** Allenamenti e partite CrAPP che contano per le statistiche di presenza. */
|
||||||
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
|
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) {
|
||||||
return eventi.filter(
|
return eventi.filter(
|
||||||
@@ -36,22 +40,87 @@ export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): nu
|
|||||||
return eventiContanoPresenze(eventi, giocatoreId).length;
|
return eventiContanoPresenze(eventi, giocatoreId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPresenze(): Promise<MappaPresenze> {
|
/**
|
||||||
|
* 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<LetturaPresenze> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("risposte_presenze")
|
.from("risposte_presenze")
|
||||||
.select("evento_id, giocatore_id, stato");
|
.select("evento_id, giocatore_id, stato, risposto_il");
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const mappa: MappaPresenze = {};
|
const presenze: MappaPresenze = {};
|
||||||
|
const tempi: MappaTempiRisposta = {};
|
||||||
for (const riga of data ?? []) {
|
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. */
|
/** Una lettura per sessione: le risposte cambiano poco durante la navigazione. */
|
||||||
export function useRispostePresenze() {
|
export function useRispostePresenze() {
|
||||||
const query = useQuery({ queryKey: PRESENZE_KEY, queryFn: fetchPresenze, staleTime: 5 * 60_000 });
|
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) {
|
export function usePresenzeEvento(eventoId: string) {
|
||||||
@@ -86,13 +155,22 @@ export function useSalvaPresenza() {
|
|||||||
},
|
},
|
||||||
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
|
// Scrittura unica + aggiornamento cache locale, nessuna rilettura.
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
queryClient.setQueryData<MappaPresenze>(PRESENZE_KEY, (prec) => {
|
queryClient.setQueryData<LetturaPresenze>(PRESENZE_KEY, (prec) => {
|
||||||
const mappa: MappaPresenze = { ...(prec ?? {}) };
|
const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) };
|
||||||
const evento = { ...(mappa[input.eventoId] ?? {}) };
|
const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) };
|
||||||
if (input.stato === null) delete evento[input.giocatoreId];
|
const stati = { ...(presenze[input.eventoId] ?? {}) };
|
||||||
else evento[input.giocatoreId] = input.stato;
|
const istanti = { ...(tempi[input.eventoId] ?? {}) };
|
||||||
mappa[input.eventoId] = evento;
|
if (input.stato === null) {
|
||||||
return mappa;
|
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 };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-7
@@ -9,7 +9,13 @@ import { useTurniPalloni } from "./palloni";
|
|||||||
import { useInfortuniERitardi } from "./infortuni";
|
import { useInfortuniERitardi } from "./infortuni";
|
||||||
import { useGiocatoreId } from "./user-store";
|
import { useGiocatoreId } from "./user-store";
|
||||||
import { useEventi } from "./eventi";
|
import { useEventi } from "./eventi";
|
||||||
import { contaPresenzeGiocatore, totaliEventiGiocatore, useRispostePresenze } from "./presenze";
|
import {
|
||||||
|
contaPresenzeGiocatore,
|
||||||
|
serieConferme,
|
||||||
|
serieConsecutiva,
|
||||||
|
totaliEventiGiocatore,
|
||||||
|
useRispostePresenze,
|
||||||
|
} from "./presenze";
|
||||||
import { obiettiviOrdinati } from "./obiettivi";
|
import { obiettiviOrdinati } from "./obiettivi";
|
||||||
import { useCsi } from "./csi";
|
import { useCsi } from "./csi";
|
||||||
import { partiteGiocate } from "./csi-core";
|
import { partiteGiocate } from "./csi-core";
|
||||||
@@ -33,7 +39,7 @@ export function useRosa(): Giocatore[] {
|
|||||||
const { turni } = useTurniPalloni();
|
const { turni } = useTurniPalloni();
|
||||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventi();
|
||||||
const { presenze: mappaPresenze } = useRispostePresenze();
|
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||||
|
|
||||||
const votiMvp = voti.data ?? [];
|
const votiMvp = voti.data ?? [];
|
||||||
|
|
||||||
@@ -54,10 +60,10 @@ export function useRosa(): Giocatore[] {
|
|||||||
iniziali: iniziali(g.nome, g.cognome),
|
iniziali: iniziali(g.nome, g.cognome),
|
||||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||||
streak: 0,
|
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||||
serieAllenamenti: 0,
|
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||||
seriePartite: 0,
|
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||||
serieConferme: 0,
|
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||||
mvp: mvpVinti[g.id] ?? 0,
|
mvp: mvpVinti[g.id] ?? 0,
|
||||||
mediaVoto: medie[g.id]?.media ?? 0,
|
mediaVoto: medie[g.id]?.media ?? 0,
|
||||||
palloni: palloni[g.id] ?? 0,
|
palloni: palloni[g.id] ?? 0,
|
||||||
@@ -66,7 +72,7 @@ export function useRosa(): Giocatore[] {
|
|||||||
infortuni: infortuni[g.id] ?? 0,
|
infortuni: infortuni[g.id] ?? 0,
|
||||||
ritardi: ritardi[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. */
|
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||||
|
|||||||
+6
-2
@@ -67,9 +67,13 @@ function messaggioSerie(valore: number, prossimo: number | null, label: string)
|
|||||||
|
|
||||||
export function statoSerie(def: SerieDef, g: Giocatore): SerieStato {
|
export function statoSerie(def: SerieDef, g: Giocatore): SerieStato {
|
||||||
const valore = def.valore(g);
|
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 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 {
|
return {
|
||||||
def,
|
def,
|
||||||
valore,
|
valore,
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -14,6 +14,7 @@ const riga: RigaEvento = {
|
|||||||
campionato: true,
|
campionato: true,
|
||||||
casa: true,
|
casa: true,
|
||||||
pagelle_chiuse: false,
|
pagelle_chiuse: false,
|
||||||
|
creato_il: "2026-08-20T09:00:00Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- daRiga: i NULL del database diventano valori sicuri ---------------------
|
// --- daRiga: i NULL del database diventano valori sicuri ---------------------
|
||||||
@@ -29,6 +30,7 @@ assert.deepEqual(daRiga(riga), {
|
|||||||
campionato: true,
|
campionato: true,
|
||||||
casa: true,
|
casa: true,
|
||||||
pagelleChiuse: false,
|
pagelleChiuse: false,
|
||||||
|
creatoIl: "2026-08-20T09:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
const vuota = daRiga({
|
const vuota = daRiga({
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -28,7 +28,14 @@ assert.equal(aDue.messaggio, "Manca solo una volta al prossimo traguardo!");
|
|||||||
|
|
||||||
const sulTraguardo = statoSerie(allenamenti, g({ serieAllenamenti: 3 }));
|
const sulTraguardo = statoSerie(allenamenti, g({ serieAllenamenti: 3 }));
|
||||||
assert.equal(sulTraguardo.prossimo, 6, "raggiunto un traguardo si punta al successivo");
|
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 }));
|
const veterano = statoSerie(allenamenti, g({ serieAllenamenti: 7 }));
|
||||||
assert.equal(veterano.messaggio, "Che continuità: ancora 3 e sali di livello.");
|
assert.equal(veterano.messaggio, "Che continuità: ancora 3 e sali di livello.");
|
||||||
|
|||||||
Reference in New Issue
Block a user