3 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 f0e6a3a6cc Documenta DD-050: avviso ad admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
Solo documentazione: nessun codice né migration. Decisione in valutazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 17:47:12 +02:00
davideandClaude Sonnet 5.5 daa0dd9c60 Porta la versione a 1.3.1: «Prossimi compleanni» nel Calendario
La sezione in fondo al Calendario mostra solo i compleanni del mese in corso non
ancora passati. Sezione 1.3.1 del CHANGELOG, versione in package.json e
package-lock.json, PROJECT_STATE e modulo calendario aggiornati. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:29:08 +02:00
davideandClaude Sonnet 5.5 bfb768fb5a Rimuove la serie «Conferme 24h» e i badge collegati
Toglie la serie dal profilo, dalla Home e dalle notifiche di traguardo, insieme
ai badge «Risposta lampo» e «Mai un forfait» e al calcolo di serieConferme.
La colonna risposto_il e il suo trigger restano nel database, inutilizzati.
Test e documentazione allineati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:21:14 +02:00
25 changed files with 161 additions and 715 deletions

No files matched your search

+4 -3
View File
@@ -9,9 +9,10 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.3.0 (tag `v1.3.0` su `main`) — notifica di compleanno, obiettivi cliccabili con card di - **Versione:** 1.3.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — il Calendario mostra solo i prossimi
dettaglio, presenze calcolate sui convocati, avvisi in Home ridisegnati, caratteri più grandi, tessera CSI compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
tolta dalla dashboard. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a - **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
+17
View File
@@ -13,6 +13,23 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.3.1] - 2026-10-06
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
### Modificato
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
(«Nessun compleanno questo mese»).
### Rimosso
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
## [1.3.0] - 2026-10-06 ## [1.3.0] - 2026-10-06
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
+1 -1
View File
@@ -77,7 +77,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. | | `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `aggiornato_il` è l'ultima modifica. | | `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. | | `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). | | `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | | `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
+1 -1
View File
@@ -12,7 +12,7 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. | | **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. | | **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). | | **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti, partite, conferme entro 24 ore). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). | | **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). | | **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). | | **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). | | **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
+67
View File
@@ -0,0 +1,67 @@
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
**Data:** 8 ottobre 2026
**Stato:** In valutazione (solo documentata: nessun codice né migration scritti)
**Contesto**
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
**Decisione**
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
evento). Scatta per **qualsiasi
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
modifica la fa un admin per conto suo.
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
ricevere notifiche, perché `notifiche_utente` è per slot.
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
notifiche e cinque email.
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
**Alternative scartate**
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
**Conseguenze**
- Nuova migration `m32` (CHECK dei tipi, due funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
`notifiche.md` si aggiornano con essa. **Va applicata in produzione** con `npx supabase db push`.
- L'allenatore, che oggi non riceve nulla sulle presenze, riceve questa notifica: va aggiunta a
[allenatore.md](../modules/allenatore.md#notifiche).
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
per evento, non togliere l'avviso.
**Riesame**
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
sono troppe o poche, spostare la soglia (una costante nella funzione).
+2 -1
View File
@@ -47,7 +47,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
**Accettate** **Accettate**
| ID | Titolo | | ID | Titolo |
| ------------------- | ------------------------------------------- | | ------------------- | --------------------------------------------------------- |
| [DD-001](DD-001.md) | Indipendenza da Lovable | | [DD-001](DD-001.md) | Indipendenza da Lovable |
| [DD-002](DD-002.md) | Sviluppo document-first | | [DD-002](DD-002.md) | Sviluppo document-first |
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive | | [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
@@ -86,6 +86,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
| [DD-038](DD-038.md) | Tre canali per ogni notifica | | [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-047](DD-047.md) | Notifica di compleanno |
+17 -23
View File
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
## Elenco badge ## Elenco badge
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
informazioni verso l'interfaccia. informazioni verso l'interfaccia.
@@ -93,7 +93,6 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 | | `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 | | `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 | | `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
### Badge segreti (booleani, nascosti finché non sbloccati) ### Badge segreti (booleani, nascosti finché non sbloccati)
@@ -105,9 +104,8 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`. `grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
| id | nome | condizione esatta | | id | nome | condizione esatta |
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) | | `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) | | `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) | | `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) | | `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
@@ -150,23 +148,21 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
### Riepilogo per badge ### Riepilogo per badge
| # | id | tipo | test unit | test integration | | # | id | tipo | test unit | test integration |
| --- | ------------------- | ------- | ----------------------------- | --------------------------------------------- | | --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) | | 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) | | 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) | | 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) | | 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) | | 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
| 6 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) | | 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) | | 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) | | 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) | | 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) | | 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) | | 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) | | 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) | | 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) | | 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
--- ---
@@ -193,11 +189,9 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
## Limiti noti ## Limiti noti
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra", - **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e rimossi il 2026-10-06.
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già - Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente. "ottenuto" può sparire o apparire retroattivamente.
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando - Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
@@ -213,7 +207,7 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
conseguenza. conseguenza.
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto` **Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
@@ -238,7 +232,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
## Evoluzioni possibili ## Evoluzioni possibili
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage. - Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero, - Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
ora che le serie sono calcolate. ora che le serie sono calcolate.
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo. - Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`. - Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
+5 -1
View File
@@ -24,7 +24,11 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori: in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio. di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
selezionato.
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori - **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)): (`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
+3 -1
View File
@@ -44,6 +44,7 @@ quelle del turno palloni**, che restano solo push e in-app: DD-040).
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì | | 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no | | 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì | | 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(proposta, DD-050: non ancora implementata)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli admin e gli allenatori attivi, tranne il giocatore a cui appartiene la presenza. | sì | sì | sì |
Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
@@ -75,6 +76,7 @@ disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `G
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` | | 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo | | 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso | | 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26` Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`, (`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
@@ -102,7 +104,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un - Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze. promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
- L'arrivo di un risultato, di una pagella o di un voto MVP. - L'arrivo di un risultato, di una pagella o di un voto MVP.
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi. - Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
- Assenze e infortuni. - Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore. - La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035). - Il certificato in scadenza: resta una card in Home (DD-035).
+13 -32
View File
@@ -1,12 +1,12 @@
# Modulo — Serie di presenze # Modulo — Serie di presenze
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali **Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`, **File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
`src/components/crapp/SerieCard.tsx` `src/components/crapp/SerieCard.tsx`
**Migration collegata:** `m9_risposte_presenze_risposto_il` **Migration collegata:** `m9_risposte_presenze_risposto_il`
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`, **Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`), `test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts` `test/integration/serie-allenamenti-badge.test.ts`
--- ---
@@ -19,15 +19,18 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
--- ---
## Le tre serie in sintesi ## Le due serie in sintesi
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi | | Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ | | ------------- | ------------------ | -------------------------------------------- | ------------ |
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 | | `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 | | `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 La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
restano nel database, ma oggi nessuna statistica li usa.
Esiste un terzo 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 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 "presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
`src/routes/profilo.tsx`. `src/routes/profilo.tsx`.
@@ -68,8 +71,8 @@ che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel te
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger. rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
| Colonna | Cosa registra | Chi la usa | | Colonna | Cosa registra | Chi la usa |
| --------------- | --------------------- | ----------------------- | | --------------- | --------------------- | -------------------------------------- |
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" | | `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica | | `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
@@ -147,26 +150,6 @@ Conseguenze da conoscere prima di cambiare qualcosa:
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti. 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`. - 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 ### Lettura e cache
`fetchPresenze()` fa **una sola query** e costruisce due mappe: `fetchPresenze()` fa **una sola query** e costruisce due mappe:
@@ -263,8 +246,6 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
| Dove | Cosa | Soglie | | Dove | Cosa | Soglie |
| ------------------------------- | -------------------------------------------------------------- | --------------------------- | | ------------------------------- | -------------------------------------------------------------- | --------------------------- |
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 | | `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 | | `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
--- ---
@@ -320,8 +301,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa **`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né `Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un `toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date` `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 (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". italiana potevano non essere d'accordo su cosa fosse "oggi".
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
-23
View File
@@ -3,9 +3,7 @@ import {
Repeat, Repeat,
Lock, Lock,
Ghost, Ghost,
Zap,
Rocket, Rocket,
Anchor,
Stethoscope, Stethoscope,
AlarmClock, AlarmClock,
ClipboardCheck, ClipboardCheck,
@@ -114,15 +112,6 @@ export const badgeDefs: BadgeDef[] = [
soglie: { bronzo: 3, argento: 6, oro: 10 }, soglie: { bronzo: 3, argento: 6, oro: 10 },
valore: (g) => g.serieAllenamenti, valore: (g) => g.serieAllenamenti,
}, },
{
id: "serie-conferme",
nome: "Risposta lampo",
descrizione: "Conferme rapide agli eventi: la squadra sa subito che ci sei.",
unita: "conferme entro 24h",
icon: Zap,
soglie: { bronzo: 3, argento: 8, oro: 15 },
valore: (g) => g.serieConferme,
},
]; ];
/** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */ /** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */
@@ -141,18 +130,6 @@ export const badgeSegreti: BadgeDef[] = [
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0), valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
celebrazione: "Nei momenti caldi ci sei sempre.", celebrazione: "Nei momenti caldi ci sei sempre.",
}, },
{
id: "s-mai-forfait",
nome: "Mai un forfait",
descrizione:
"Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
unita: "requisito nascosto",
icon: Anchor,
segreto: true,
soglie: { bronzo: 1, argento: 1, oro: 1 },
valore: (g) => (g.serieConferme >= 10 && g.presenze >= 15 ? 1 : 0),
celebrazione: "Su di te la squadra può contare a occhi chiusi.",
},
{ {
id: "s-infermeria", id: "s-infermeria",
nome: "Cliente VIP dell'Infermeria", nome: "Cliente VIP dell'Infermeria",
-2
View File
@@ -34,7 +34,6 @@ export type Giocatore = {
/** Serie consecutive per tipo: si azzerano in modo indipendente. */ /** Serie consecutive per tipo: si azzerano in modo indipendente. */
serieAllenamenti: number; serieAllenamenti: number;
seriePartite: number; seriePartite: number;
serieConferme: number;
/** MVP eletti dalla squadra. */ /** MVP eletti dalla squadra. */
mvp: number; mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */ /** Media delle pagelle ricevute dai compagni (1-10). */
@@ -109,7 +108,6 @@ export const giocatori: Giocatore[] = rosaCSI
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
mvp: 0, mvp: 0,
mediaVoto: 0, mediaVoto: 0,
votiPagella: 0, votiPagella: 0,
+1 -26
View File
@@ -155,31 +155,6 @@ export function serieConsecutiva(
); );
} }
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 = dataOggi(),
): 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;
},
);
}
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */ /** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
function serieSu( function serieSu(
giocatoreId: string, giocatoreId: string,
@@ -228,7 +203,7 @@ export function usePresenzeEvento(eventoId: string) {
* *
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il` * - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima, * non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h"; * e un ripensamento non deve cambiare l'istante della prima risposta;
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore * - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta. * risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
*/ */
+2 -15
View File
@@ -12,7 +12,6 @@ import { useEventi } from "./eventi";
import { import {
contaPartiteGiocate, contaPartiteGiocate,
contaPresenzeGiocatore, contaPresenzeGiocatore,
serieConferme,
serieConsecutiva, serieConsecutiva,
totaliEventiGiocatore, totaliEventiGiocatore,
useRispostePresenze, useRispostePresenze,
@@ -68,7 +67,7 @@ export function useRosa(): Giocatore[] {
const { salvati: turniSalvati } = useTurniPalloni(); const { salvati: turniSalvati } = useTurniPalloni();
const { infortuni, ritardi } = useInfortuniERitardi(); const { infortuni, ritardi } = useInfortuniERitardi();
const { eventi } = useEventi(); const { eventi } = useEventi();
const { presenze: mappaPresenze, tempi } = useRispostePresenze(); const { presenze: mappaPresenze } = useRispostePresenze();
const votiMvp = voti.data; const votiMvp = voti.data;
@@ -94,7 +93,6 @@ export function useRosa(): Giocatore[] {
streak: serieConsecutiva(g.id, eventi, mappaPresenze), streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
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,
votiPagella: medie[g.id]?.voti ?? 0, votiPagella: medie[g.id]?.voti ?? 0,
@@ -105,18 +103,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, turniSalvati, infortuni, ritardi, eventi, mappaPresenze]);
squadra,
votiMvp,
pagelle,
cacche,
turniSalvati,
infortuni,
ritardi,
eventi,
mappaPresenze,
tempi,
]);
} }
/** Criteri di ordinamento della classifica interna di Squadra. */ /** Criteri di ordinamento della classifica interna di Squadra. */
+2 -10
View File
@@ -1,8 +1,8 @@
import { Dumbbell, Swords, Zap } from "lucide-react"; import { Dumbbell, Swords } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
export type SerieTipo = "allenamenti" | "partite" | "conferme"; export type SerieTipo = "allenamenti" | "partite";
export type SerieDef = { export type SerieDef = {
tipo: SerieTipo; tipo: SerieTipo;
@@ -31,14 +31,6 @@ export const serieDefs: SerieDef[] = [
traguardi: [2, 5, 8, 12], traguardi: [2, 5, 8, 12],
valore: (g) => g.seriePartite, valore: (g) => g.seriePartite,
}, },
{
tipo: "conferme",
label: "Conferme 24h",
descrizione: "Risposte date entro 24 ore dalla convocazione",
icon: Zap,
traguardi: [3, 8, 15, 20],
valore: (g) => g.serieConferme,
},
]; ];
export type SerieStato = { export type SerieStato = {
+13 -4
View File
@@ -90,6 +90,13 @@ function Calendario() {
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia). // Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null; const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : []; const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
// Solo i compleanni del mese in corso non ancora passati (oggi compreso), a prescindere dal mese
// mostrato nella griglia.
const compleanniMeseCorrente =
oggi && oggiIso
? compleanniEventi(rosa, oggi.anno).filter((c) => c.data.startsWith(oggiIso.slice(0, 7)))
: [];
const prossimiCompleanni = oggiIso ? compleanniMeseCorrente.filter((c) => c.data >= oggiIso) : [];
return ( return (
<> <>
@@ -270,13 +277,15 @@ function Calendario() {
</div> </div>
</Section> </Section>
<Section titolo="Compleanni"> <Section titolo="Prossimi compleanni">
<div className="space-y-3"> <div className="space-y-3">
{compleanniMese.length > 0 ? ( {prossimiCompleanni.length > 0 ? (
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />) prossimiCompleanni.map((c) => <EventoCard key={c.id} evento={c} />)
) : ( ) : (
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card"> <p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun compleanno in {mesiIT[mese]!.toLowerCase()} {compleanniMeseCorrente.length > 0
? "Nessun altro compleanno questo mese"
: "Nessun compleanno questo mese"}
</p> </p>
)} )}
</div> </div>
@@ -1,204 +0,0 @@
/**
* Badge segreto Mai un forfait end-to-end contro il database locale:
* `bun test/integration/s-mai-forfait-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece
* si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su
* `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso
* entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a
* combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali).
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge } from "@/lib/badges";
import { serieConferme, contaPresenzeGiocatore } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
import type { Stato } from "@/lib/crapp-data";
import type { MappaPresenze } from "@/lib/presenze";
const locale = statoLocale();
if (!locale) {
salta("badge Mai un forfait sul database", "stack locale non attivo (npx supabase start)");
riepilogo("s-mai-forfait-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Mai un forfait su ${URL_BASE}`);
const PREFISSO = "test-s-mai-forfait-badge";
const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!;
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
async function creaEvento(id: string, i: number) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo: "allenamento",
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: `${dataEvento(i)}T08:00:00Z`,
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(eventoId: string, giocatoreId: string, i: number) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato: "presente",
risposto_il: `${dataEvento(i)}T08:30:00Z`,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
async function leggiPresenzeETempi(): Promise<{
presenze: MappaPresenze;
tempi: Record<string, Record<string, string>>;
}> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
stato: Stato;
risposto_il: string;
}>;
const presenze: MappaPresenze = {};
const tempi: Record<string, Record<string, string>> = {};
for (const r of righe) {
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
(tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
}
return { presenze, tempi };
}
function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
presenze,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
};
}
try {
await prova(
"il segreto si sblocca solo quando entrambe le condizioni sono vere insieme",
async () => {
// "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa
// ampiamente sia la serie di conferme (>=10) sia le presenze (>=15).
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "mf1", i);
}
const eventi15 = await leggiEventi();
const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi();
// Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia.
const eventi9 = eventi15.slice(0, 9);
const serie9 = serieConferme("mf1", eventi9, t15, OGGI);
const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI);
assert.equal(serie9, 9);
assert.equal(pres9, 9);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado,
null,
"9 conferme e 9 presenze: nessuna delle due soglie raggiunta",
);
const serie15 = serieConferme("mf1", eventi15, t15, OGGI);
const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI);
assert.equal(serie15, 15);
assert.equal(pres15, 15);
assert.equal(
statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado,
"oro",
"serie conferme >=10 e presenze >=15: il segreto si sblocca",
);
},
);
await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => {
// Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0,
// ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve
// ripartire da zero anche sulle presenze.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: idLento,
giocatore_id: "mf1",
stato: "presente",
risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione
}),
});
if (!res.ok)
throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
const eventi = await leggiEventi();
const { presenze, tempi } = await leggiPresenzeETempi();
const serie = serieConferme("mf1", eventi, tempi, OGGI);
const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI);
assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme");
assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie");
assert.equal(
statoBadge(def, giocatoreAzzerato(serie, conto)).grado,
null,
"presenze abbondanti ma serie azzerata: il segreto si richiude",
);
});
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("s-mai-forfait-badge");
}
+2 -2
View File
@@ -254,8 +254,8 @@ if (!locale) {
// --- presenze: la risposta si cambia, il cronometro no -------------------------- // --- presenze: la risposta si cambia, il cronometro no --------------------------
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA // Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non // risposta, `aggiornato_il` è l'ultima modifica; oggi nessuna statistica le usa (la serie
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma // «Conferme 24h» è stata rimossa). Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento. // la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
await prova( await prova(
"la risposta di presenza si aggiorna senza far ripartire il cronometro", "la risposta di presenza si aggiorna senza far ripartire il cronometro",
@@ -1,229 +0,0 @@
/**
* Badge Risposta lampo end-to-end contro il database locale:
* `bun test/integration/serie-conferme-badge.test.ts`.
*
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
* `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
* mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte
* vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST
* con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso
* `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia
* nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` si rompe, qui il grado
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
* mai il database.
*
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
* - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una
* striscia lunga;
* - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { serieConferme } from "@/lib/presenze";
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Risposta lampo sul database", "stack locale non attivo (npx supabase start)");
riepilogo("serie-conferme-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Risposta lampo su ${URL_BASE}`);
const PREFISSO = "test-serie-conferme-badge";
const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
// restano "passati" (quindi contati) a prescindere da quando gira il test.
const OGGI = "2099-01-01";
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
const dataEvento = (i: number) => {
const d = new Date(Date.UTC(2020, 0, 1));
d.setUTCDate(d.getUTCDate() + i - 1);
return d.toISOString().slice(0, 10);
};
/** Istante di convocazione: le 8 del mattino del giorno dell'evento. */
const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`;
async function creaEvento(
id: string,
i: number,
tipo: "allenamento" | "partita" = "allenamento",
) {
const res = await rest("eventi_app", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
id,
tipo,
titolo: `Evento ${id}`,
data: dataEvento(i),
creato_il: creazioneEvento(i),
}),
});
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
}
async function rispondi(
eventoId: string,
giocatoreId: string,
stato: string,
rispostoIl: string,
) {
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: eventoId,
giocatore_id: giocatoreId,
stato,
risposto_il: rispostoIl,
}),
});
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
}
async function leggiEventi(): Promise<Evento[]> {
const res = await rest(
"eventi_app?id=like." +
PREFISSO +
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
);
const righe = (await res.json()) as RigaEvento[];
return righe.map(daRiga);
}
/** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */
async function leggiTempi(): Promise<Record<string, Record<string, string>>> {
const res = await rest(
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`,
);
const righe = (await res.json()) as Array<{
evento_id: string;
giocatore_id: string;
risposto_il: string;
}>;
const mappa: Record<string, Record<string, string>> = {};
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
return mappa;
}
function giocatoreAzzerato(serieConferme: number): Giocatore {
return {
...giocatori[0]!,
serieConferme,
mvp: 0,
mediaVoto: 0,
votiPagella: 0,
palloni: 0,
presenze: 0,
};
}
try {
await prova(
"il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera",
async () => {
// 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1":
// bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati.
for (let i = 1; i <= 15; i += 1) {
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
await creaEvento(id, i);
await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`);
}
const eventi15 = await leggiEventi();
const tempi15 = await leggiTempi();
const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI);
assert.equal(serieA3, 3);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
"bronzo",
"3 conferme rapide di fila: soglia bronzo",
);
const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI);
assert.equal(serieA8, 8);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado,
"argento",
"8 conferme rapide di fila: soglia argento",
);
const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI);
assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
"oro",
"15 conferme rapide di fila: soglia oro",
);
// Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie.
const idLento = `${PREFISSO}-a16`;
await creaEvento(idLento, 16);
await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`);
const eventiConRitardo = await leggiEventi();
const tempiConRitardo = await leggiTempi();
const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI);
assert.equal(
serieDopoRitardo,
0,
"una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila",
);
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado,
null,
"serie azzerata: nessun grado, si riparte da zero",
);
},
);
await prova(
"partite e allenamenti contano nella stessa serie, senza filtro per tipo",
async () => {
// "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro
// allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo.
await creaEvento(`${PREFISSO}-b01`, 30, "allenamento");
await creaEvento(`${PREFISSO}-b02`, 31, "partita");
await creaEvento(`${PREFISSO}-b03`, 32, "allenamento");
await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`);
await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`);
const eventiLetti = await leggiEventi();
const tempiLetti = await leggiTempi();
const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI);
assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3");
assert.equal(
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
"bronzo",
"3 conferme rapide di fila, tipo misto: soglia bronzo",
);
},
);
} finally {
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("serie-conferme-badge");
}
-42
View File
@@ -34,7 +34,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
...valori, ...valori,
}; };
} }
@@ -146,16 +145,6 @@ assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro");
assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro"); assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro");
// --- serie-conferme: soglie 3 / 8 / 15 ----------------------------------------
const serieConfermeDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
assert.equal(gradoRaggiunto(serieConfermeDef, 2), null, "sotto la prima soglia nessun grado");
assert.equal(gradoRaggiunto(serieConfermeDef, 3), "bronzo", "la soglia è inclusiva");
assert.equal(gradoRaggiunto(serieConfermeDef, 7), "bronzo");
assert.equal(gradoRaggiunto(serieConfermeDef, 8), "argento");
assert.equal(gradoRaggiunto(serieConfermeDef, 14), "argento");
assert.equal(gradoRaggiunto(serieConfermeDef, 15), "oro");
assert.equal(gradoRaggiunto(serieConfermeDef, 50), "oro", "oltre l'oro resta oro");
// --- badgeGiocatore ---------------------------------------------------------- // --- badgeGiocatore ----------------------------------------------------------
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
assert.ok( assert.ok(
@@ -200,36 +189,6 @@ assert.deepEqual(
["s-cacche"], ["s-cacche"],
); );
assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano"); assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano");
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
["s-mai-forfait"],
);
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 14 })).length,
0,
"servono entrambe le condizioni",
);
// Confini isolati: ogni soglia testata da sola, con l'altra abbondantemente sopra.
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 9, presenze: 30 })).length,
0,
"serieConferme appena sotto soglia, presenze abbondanti: non basta",
);
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 30 })).map((b) => b.def.id),
["s-mai-forfait"],
"serieConferme esattamente al confine, presenze abbondanti: sblocca",
);
assert.equal(
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 14 })).length,
0,
"presenze appena sotto soglia, serieConferme abbondante: non basta",
);
assert.deepEqual(
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 15 })).map((b) => b.def.id),
["s-mai-forfait"],
"presenze esattamente al confine, serieConferme abbondante: sblocca",
);
// --- collezioneBadge --------------------------------------------------------- // --- collezioneBadge ---------------------------------------------------------
const vuota = collezioneBadge(g()); const vuota = collezioneBadge(g());
@@ -262,7 +221,6 @@ assert.equal(
palloni: 10, palloni: 10,
presenze: 30, presenze: 30,
serieAllenamenti: 10, serieAllenamenti: 10,
serieConferme: 15,
}), }),
), ),
null, null,
+1 -2
View File
@@ -19,7 +19,6 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
streak: 0, streak: 0,
serieAllenamenti: 0, serieAllenamenti: 0,
seriePartite: 0, seriePartite: 0,
serieConferme: 0,
...valori, ...valori,
}; };
} }
@@ -57,7 +56,7 @@ assert.ok(
// --- serie: solo sul traguardo esatto ---------------------------------------- // --- serie: solo sul traguardo esatto ----------------------------------------
assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3")); assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3"));
assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo"); assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo");
assert.ok(ids(g({ serieConferme: 8 })).includes("serie:conferme:8")); assert.ok(ids(g({ seriePartite: 5 })).includes("serie:partite:5"));
// --- obiettivi di squadra: uno solo, tra il 90% e il 99% -------------------- // --- obiettivi di squadra: uno solo, tra il 90% e il 99% --------------------
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
+1 -84
View File
@@ -7,7 +7,6 @@ import {
contaPresenzeGiocatore, contaPresenzeGiocatore,
destinatariSollecito, destinatariSollecito,
includeInfortunato, includeInfortunato,
serieConferme,
serieConsecutiva, serieConsecutiva,
testoSollecito, testoSollecito,
totaliEventiGiocatore, totaliEventiGiocatore,
@@ -218,91 +217,9 @@ assert.ok(
"senza luogo la riga manca", "senza luogo la riga manca",
); );
// --- 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");
// Chi non è convocato non spezza la serie conferme di nessun altro (stesso filtro condiviso
// con `serieConsecutiva`, vedi sopra).
const c6Ristretto: Evento = {
...convocati("c6", "2026-08-08", "2026-08-03T10:00:00Z"),
convocati: ["g9"],
};
assert.equal(
serieConferme("g1", [conConvocazione[0]!, c6Ristretto], tempi, OGGI),
1,
"un evento convocato solo per un altro giocatore resta fuori, la serie di g1 non ne risente",
);
// Le conferme contano partite e allenamenti insieme: nessuna versione per tipo.
const mistiTipo: Evento[] = [
convocati("m1", "2026-08-06", "2026-08-01T10:00:00Z"),
{ ...convocati("m2", "2026-08-13", "2026-08-08T10:00:00Z"), tipo: "partita" },
];
const tempiMisti: MappaTempiRisposta = {
m1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo
m2: { g1: "2026-08-08T11:00:00Z" }, // un'ora dopo, ma è una partita
};
assert.equal(
serieConferme("g1", mistiTipo, tempiMisti, OGGI),
2,
"partite e allenamenti si sommano nella stessa serie",
);
// Il confronto con le 24h è inclusivo: esattamente al bordo conta, un secondo oltre azzera.
const bordo: Evento[] = [convocati("b1", "2026-08-06", "2026-08-01T10:00:00Z")];
assert.equal(
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:00Z" } }, OGGI),
1,
"esattamente 24h dopo: il confronto è <=, quindi conta",
);
assert.equal(
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:01Z" } }, OGGI),
0,
"un secondo oltre le 24h azzera",
);
// Un evento futuro non conta ancora, anche con una risposta rapidissima già registrata.
const futuro: Evento[] = [convocati("f1", "2026-09-10", "2026-09-05T10:00:00Z")];
assert.equal(
serieConferme("g1", futuro, { f1: { g1: "2026-09-05T10:05:00Z" } }, OGGI),
0,
"l'evento di domani non è ancora passato, non entra nel calcolo",
);
// --- cache locale dopo una risposta: il cronometro non riparte ---------------- // --- cache locale dopo una risposta: il cronometro non riparte ----------------
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela. // Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
// Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh. // Qui la cache deve imitarla, altrimenti la cache mente fino al refresh.
const PRIMA = "2026-08-01T10:00:00Z"; const PRIMA = "2026-08-01T10:00:00Z";
const POI = "2026-08-08T10:00:00Z"; const POI = "2026-08-08T10:00:00Z";
+6 -6
View File
@@ -4,7 +4,7 @@ import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie"; import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
function g(valori: Partial<Giocatore> = {}): Giocatore { function g(valori: Partial<Giocatore> = {}): Giocatore {
return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, serieConferme: 0, ...valori }; return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, ...valori };
} }
const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!; const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!;
@@ -45,16 +45,16 @@ assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso],
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!"); assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
// --- serieGiocatore / serieMigliore ------------------------------------------ // --- serieGiocatore / serieMigliore ------------------------------------------
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 6 }));
assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti"); assert.equal(tutte.length, serieDefs.length, "due serie indipendenti");
assert.deepEqual( assert.deepEqual(
tutte.map((s) => s.valore), tutte.map((s) => s.valore),
[4, 1, 9], [4, 6],
"ogni serie legge il proprio contatore", "ogni serie legge il proprio contatore",
); );
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 })); const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 6 }));
assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga"); assert.equal(migliore.def.tipo, "partite", "in home si mostra la serie più lunga");
assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare"); assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare");
// --- invarianti sulle definizioni -------------------------------------------- // --- invarianti sulle definizioni --------------------------------------------