Compare commits
42
Commits
fc65914f55
...
04e80a3531
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e80a3531 | ||
|
|
5569d08b59 | ||
|
|
c628997934 | ||
|
|
8353761b6e | ||
|
|
15f058f760 | ||
|
|
8dfd81ebe4 | ||
|
|
bef5542ce3 | ||
|
|
57463f993b | ||
|
|
c7cd527045 | ||
|
|
00235d1594 | ||
|
|
6d177d5d8a | ||
|
|
89b08349c4 | ||
|
|
f1d61ebd6e | ||
|
|
dbf60d3a7e | ||
|
|
b0c1e2f826 | ||
|
|
0eb2d60569 | ||
|
|
3b305b0f8e | ||
|
|
a451d4187f | ||
|
|
8a692bfe2f | ||
|
|
cde01ff02b | ||
|
|
d250f41749 | ||
|
|
4fb05748c3 | ||
|
|
10d1455eff | ||
|
|
e6a222f196 | ||
|
|
1fdc6720a0 | ||
|
|
080379cc64 | ||
|
|
8c9cf370e8 | ||
|
|
c7588c5aa2 | ||
|
|
9b81fcf2fc | ||
|
|
242941740b | ||
|
|
7e51fe6345 | ||
|
|
8c6fceb682 | ||
|
|
0dadabfea6 | ||
|
|
261578357a | ||
|
|
447016f03b | ||
|
|
e13722eae9 | ||
|
|
2b30dd4708 | ||
|
|
440bf0cd56 | ||
|
|
8add483a5b | ||
|
|
3258a24f20 | ||
|
|
82b6c6b333 | ||
|
|
08b00627e5 |
+28
-6
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 06/09/2026
|
Ultimo aggiornamento: 09/09/2026
|
||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard
|
|||||||
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`.
|
||||||
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora
|
||||||
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati
|
||||||
reali (M9).
|
reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare
|
||||||
|
un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da
|
||||||
|
cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,7 +23,8 @@ reali (M9).
|
|||||||
- Cursor e Claude Code come ambienti di sviluppo
|
- Cursor e Claude Code come ambienti di sviluppo
|
||||||
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
- Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production)
|
||||||
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
- Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes`
|
||||||
- 23 migration in `supabase/migrations/`, fino a `m12_niente_autovoto`
|
- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani`
|
||||||
|
(09/09/2026)
|
||||||
- Sviluppo locale verificato con il nuovo Supabase
|
- Sviluppo locale verificato con il nuovo Supabase
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -36,8 +39,9 @@ reali (M9).
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
- Schema v1.0 e migration da M1 a M12 applicate al nuovo Supabase (`m12_niente_autovoto`
|
- Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase
|
||||||
in produzione dal 06/09/2026, verificata con `npx supabase migration list`)
|
(`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con
|
||||||
|
`npx supabase migration list`)
|
||||||
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
- `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`)
|
||||||
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori
|
||||||
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza
|
||||||
@@ -61,6 +65,19 @@ reali (M9).
|
|||||||
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
|
scoutate concluse, e collegamento della tabella `scout_sessioni` (già presente nello
|
||||||
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
|
schema ma mai usata) al blocco condiviso dello Scout Live — prima entrambi vivevano solo
|
||||||
in `localStorage`, quindi visibili a un solo dispositivo
|
in `localStorage`, quindi visibili a un solo dispositivo
|
||||||
|
- Migration `m13_convocati_e_pagelle_chiuse`: le RLS di `mvp_voti`/`pagelle_voti`/
|
||||||
|
`badge_social_voti` richiedono che votante e votato siano convocati all'evento (e per le
|
||||||
|
pagelle anche `pagelle_chiuse = false`)
|
||||||
|
- Migration `m14_pulizia_dati_evento_cancellato` (DD-029): cancellare un evento pulisce a
|
||||||
|
cascata, tramite trigger, tutte le tabelle collegate (`risposte_presenze`,
|
||||||
|
`cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
|
||||||
|
`scout_sessioni`, `scout_live`, `scout_partite`)
|
||||||
|
- Migration `m15_bonifica_dati_evento_orfani`: bonifica una tantum delle righe orfane da
|
||||||
|
cancellazioni di eventi precedenti a M14, senza toccare i vecchi voti MVP/pagelle/badge
|
||||||
|
social legati a id Scout o CSI
|
||||||
|
- Migration `m16_funzione_bonifica_dati_evento_orfani`: la stessa logica di bonifica di M15
|
||||||
|
resta richiamabile come funzione `bonifica_dati_evento_orfani()` (riservata al service
|
||||||
|
role), se mai servisse di nuovo
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,13 +85,18 @@ reali (M9).
|
|||||||
|
|
||||||
- Squadra
|
- Squadra
|
||||||
- Presenze
|
- Presenze
|
||||||
- Badge
|
- Badge (incluso badge social)
|
||||||
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
- Scout Live (blocco e archivio partite sincronizzati tra dispositivi, migration `m7_scout_partite`)
|
||||||
- Pagelle
|
- Pagelle
|
||||||
- MVP
|
- MVP
|
||||||
|
- Obiettivi di squadra
|
||||||
|
- Turno palloni (specifica in `docs/modules/palloni.md`)
|
||||||
|
- Infortuni, in forma minima (specifica in `docs/modules/infortuni.md`)
|
||||||
- Notifiche
|
- Notifiche
|
||||||
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
- Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||||
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
- Serie di presenze (specifica in `docs/modules/serie-presenze.md`)
|
||||||
|
- Collegamento CSI: classifica campionato e Coppa, storico e dettaglio partita — formazioni,
|
||||||
|
scontri diretti (specifica in `docs/modules/collegamento-csi.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+47
-73
@@ -1,84 +1,58 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Tutte le modifiche significative del progetto, dalla più recente. Il formato segue
|
Le modifiche rilevanti di CrAPP sono documentate qui, in ordine di rilascio. Il formato
|
||||||
[Keep a Changelog](https://keepachangelog.com/it/1.1.0/) e la numerazione
|
segue [Keep a Changelog](https://keepachangelog.com/it/1.1.0/): ogni versione ha una data
|
||||||
[Semantic Versioning](https://semver.org/lang/it/): il numero di versione è quello di
|
e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elenco
|
||||||
`package.json`.
|
completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si
|
||||||
|
registra solo _quando_ una voce è stata rilasciata e con quale versione.
|
||||||
|
|
||||||
L'elenco delle funzionalità disponibili e previste non si ripete qui: sta in
|
Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`):
|
||||||
[ROADMAP.md](ROADMAP.md), che elenca il _cosa_ senza numeri di versione — quelli stanno solo
|
finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili
|
||||||
qui.
|
all'indietro.
|
||||||
|
|
||||||
## [Non rilasciato] — 0.9.0
|
## [Non rilasciato]
|
||||||
|
|
||||||
Prima versione, pre-release.
|
## [0.9.0] - 2026-09-09
|
||||||
|
|
||||||
|
Prima versione pre-release: lo sviluppo precedente non era versionato a parte, quindi
|
||||||
|
questa release riunisce tutto ciò che l'app fa oggi in produzione.
|
||||||
|
|
||||||
### Aggiunto
|
### Aggiunto
|
||||||
|
|
||||||
- Login con Google tramite Supabase Auth e collegamento automatico dell'account al proprio
|
- **Gestione squadra** — rosa dei giocatori con ruoli e dati anagrafici di base.
|
||||||
giocatore confrontando l'email (DD-011, DD-018; migration `m5_email_giocatori_squadra`):
|
- **Profilo Giocatore** — dati personali e amministrativi, documento d'identità,
|
||||||
senza sessione non si entra in nessuna schermata.
|
certificato medico (caricamento, scadenza, stato, download) e foto tessera in
|
||||||
- Dashboard amministratore `/admin`: stato dei profili, download di documento, certificato e
|
un'unica schermata, sia lato giocatore sia lato amministratore; lo storico dei
|
||||||
foto tessera, export CSV per il tesseramento, aggiunta e disattivazione dei giocatori
|
certificati resta un'estensione futura.
|
||||||
(la riga non viene mai eliminata, così presenze, voti e badge restano agganciati al suo id).
|
- **Gestione tesseramenti CSI** — raccolta dei dati richiesti dal CSI, tracciamento di chi
|
||||||
- Profilo giocatore: dati anagrafici, documento, certificato medico e foto tessera con le
|
è già tesserato (numero e data tessera) ed export CSV per il tesseramento.
|
||||||
relative scadenze (migration `m2_profili_giocatore`, bucket privato), divisi nelle tab
|
- **Calendario** — eventi di allenamento e partita, con schermata di dettaglio dedicata.
|
||||||
Stagione, Documenti e Opzioni — [modules/profilo-giocatore.md](modules/profilo-giocatore.md).
|
- **Presenze** — conferma o rifiuto della partecipazione a un evento, visibile a tutta la
|
||||||
- Tracciamento del tesseramento CSI: numero e data di tessera in `giocatori_squadra`, badge e
|
squadra al posto di chat e fogli condivisi.
|
||||||
contatore in dashboard (migration `m8_tesseramento_csi`).
|
- **Serie di presenze** — tre serie (presenze, conferme, allenamenti) calcolate sui dati
|
||||||
- Foto profilo condivise tra dispositivi tramite il bucket pubblico `avatar-giocatori`
|
reali della rosa.
|
||||||
(migration `m6_avatar_giocatori`).
|
- **Scout Live** — un solo referente alla volta registra in tempo reale le azioni di gioco
|
||||||
- Serie di presenze calcolate sui dati reali (`serieConsecutiva()`), con la colonna
|
durante la partita.
|
||||||
`risposto_il` che congela l'istante della **prima** risposta tramite trigger (migration
|
- **Badge** — gamification con gradi bronzo/argento/oro, badge segreti e badge social
|
||||||
`m9_risposte_presenze_risposto_il`): sblocca la serie "Conferme 24h" e i badge "Risposta
|
votati tra compagni.
|
||||||
lampo" e "Mai un forfait" — [modules/serie-presenze.md](modules/serie-presenze.md).
|
- **Pagelle** — voto tra compagni (1-10) a fine partita, con media personale e di squadra.
|
||||||
- Scout Live sincronizzato tra dispositivi: il blocco "chi sta scoutando" passa dalla tabella
|
- **Votazione MVP** — elezione del migliore in campo della partita tramite voto tra
|
||||||
`scout_sessioni` e le partite concluse vengono archiviate in `scout_partite` (migration
|
compagni, un voto a testa.
|
||||||
`m7_scout_partite`). Si apre dalla sezione «Scout live» di `/partita/$id`, solo il giorno
|
- **Obiettivi di squadra** — traguardi collettivi che avanzano con presenze, risposte alle
|
||||||
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
|
convocazioni, pagelle e risultati di campionato.
|
||||||
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
|
- **Turno palloni** — rotazione condivisa e promemoria di chi porta e riporta i palloni ad
|
||||||
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
|
allenamenti e partite.
|
||||||
- Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio
|
- **Notifiche push** — promemoria intelligenti su un unico opt-in per dispositivo.
|
||||||
(poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per
|
- **Dashboard amministratore** — vista aggregata su tesseramenti, certificati, presenze e
|
||||||
gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
|
dati della rosa, con download CSV.
|
||||||
- Turni palloni con rotazione automatica sulle partite e assegnazione manuale per gli
|
- **Collegamento CSI** — classifica di campionato e Coppa, storico partite e dettaglio di
|
||||||
allenamenti, che restano «da assegnare» finché non si sceglie (migration M10).
|
ogni gara (formazioni, storico scontri diretti, probabilità di vittoria calcolata dal
|
||||||
- Notifiche push con il testo cifrato **dentro** la push (`aes128gcm`, RFC 8291), così
|
CSI) letti in tempo reale dal portale ufficiale Livescore CSI Bologna, senza inserimento
|
||||||
arrivano anche ad app chiusa e a schermo bloccato (DD-026).
|
manuale da parte degli amministratori.
|
||||||
- Collegamento CSI: classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
|
- **Infortuni** — conteggio degli eventi saltati per infortunio, riusando lo stato di
|
||||||
(stagione 2025/26, Open Misto Eccellenza, Girone B) —
|
presenza già registrato per le convocazioni.
|
||||||
[modules/collegamento-csi.md](modules/collegamento-csi.md).
|
|
||||||
- Note dell'evento visibili in `/allenamento/$id` e `/partita/$id`, con gli a capo mantenuti.
|
|
||||||
- «Segnala un bug» e «Suggerisci una nuova funzionalità» in `/profilo`: due link che aprono
|
|
||||||
una issue GitHub sul template giusto, senza tabelle né schermate di gestione.
|
|
||||||
- Interfaccia accessibile: contrasto dei token colore sopra 4.5:1, `viewport-fit=cover` e
|
|
||||||
`theme-color` coerenti con un'app chiara, `lang="it"`, `:focus-visible` globale, tocchi da
|
|
||||||
44px, `aria-current`/`aria-pressed`/`aria-controls`/`aria-busy`, niente testo sotto i 12px.
|
|
||||||
- Movimento con molle interrompibili di `motion` al posto delle `@keyframes` a durata fissa,
|
|
||||||
swipe fra i mesi del calendario e barre di progresso che misurano l'avanzamento tra un
|
|
||||||
traguardo e il successivo (DD-021).
|
|
||||||
- Suite di test in `test/` (unit, integration, end-to-end) eseguita con bun e senza nuove
|
|
||||||
dipendenze: `npm run test` e `npm run test:all`.
|
|
||||||
- Convenzioni interne: primitive condivise (`Card`, `Campo`, `classiInput` in `ui-bits`),
|
|
||||||
cache aggiornata con `setQueryData` invece di rileggere il database dopo ogni scrittura, e
|
|
||||||
logica pura estratta in `src/lib/` con i suoi test.
|
|
||||||
- Infrastruttura di sviluppo: migrazione da Lovable a sviluppo locale, repository GitHub
|
|
||||||
indipendente, deploy automatico su Vercel.
|
|
||||||
|
|
||||||
### Sicurezza
|
### Sicurezza
|
||||||
|
|
||||||
- Migration `m4_solo_autenticati`: tolto al ruolo `anon` l'accesso alle tabelle dell'app
|
- Autenticazione tramite Google via Supabase Auth, unico metodo di accesso; permessi
|
||||||
(applicata in produzione il 03/09/2026).
|
differenziati per ruolo (giocatore/amministratore) su tabelle e route.
|
||||||
- I permessi di amministrazione arrivano solo da `user_roles` (`src/lib/ruoli.ts`): senza,
|
|
||||||
basterebbe scegliere il nome giusto per amministrare.
|
|
||||||
- Migration `m11_scritture_per_ruolo`: ogni voto è firmato con lo slot collegato all'account
|
|
||||||
(DD-023); il collegamento account → giocatore non è modificabile dal giocatore stesso
|
|
||||||
(DD-016).
|
|
||||||
- Migration `m12_niente_autovoto`: i vincoli `mvp_no_autovoto` e `badge_social_no_autovoto`
|
|
||||||
rifiutano l'auto-voto anche a chi scrive direttamente su PostgREST, come già faceva
|
|
||||||
`pagelle_voti`.
|
|
||||||
- Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è
|
|
||||||
applicativo, non RLS ([modules/mvp.md](modules/mvp.md)).
|
|
||||||
- La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di
|
|
||||||
`badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva
|
|
||||||
`risposto_il` (trigger di `m9`).
|
|
||||||
|
|||||||
+5
-5
@@ -15,7 +15,7 @@ nessuna di esse da M4 (DD-011).
|
|||||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
|
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
|
||||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
|
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
|
||||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin |
|
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
|
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
|
||||||
@@ -46,7 +46,7 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
|||||||
|
|
||||||
| 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. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. |
|
||||||
| `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. |
|
| `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). |
|
||||||
@@ -63,9 +63,9 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
|
|||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`). |
|
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. |
|
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
|
||||||
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). |
|
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
|
||||||
|
|
||||||
## Turni e notifiche
|
## Turni e notifiche
|
||||||
|
|
||||||
|
|||||||
+214
-29
@@ -25,13 +25,13 @@ Serve a rispondere a domande del tipo:
|
|||||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||||
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
|
| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API |
|
||||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
|
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati |
|
||||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||||
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
|
| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID |
|
||||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore |
|
||||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||||
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email |
|
||||||
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
|
| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente |
|
||||||
@@ -42,6 +42,9 @@ Serve a rispondere a domande del tipo:
|
|||||||
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
|
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
|
||||||
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
|
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
|
||||||
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
|
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
|
||||||
|
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
|
||||||
|
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
|
||||||
|
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
|
||||||
|
|
||||||
**In valutazione**
|
**In valutazione**
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ Ogni nuova funzionalità significativa viene prima **progettata e documentata**
|
|||||||
**Conseguenze**
|
**Conseguenze**
|
||||||
|
|
||||||
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
|
- Rallenta leggermente l’avvio di nuove feature, ma riduce rework e discussioni infinite.
|
||||||
- I moduli v1.0 vanno retro-documentati quando possibile.
|
- I moduli preesistenti vanno retro-documentati quando possibile.
|
||||||
- Nessuna feature non documentata entra in produzione.
|
- Nessuna feature non documentata entra in produzione.
|
||||||
|
|
||||||
**Riesame**
|
**Riesame**
|
||||||
@@ -184,7 +187,7 @@ indietro. Vedi DD-019.
|
|||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
CrAPP v1.0 è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
|
CrAPP è già usata dalla squadra per presenze, calendario, scout, badge e notifiche. Rischiare regressioni su moduli funzionanti vanifica la fiducia degli utenti.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
|
Le nuove versioni **introducono** funzionalità. Non si riscrive un modulo già operativo salvo richiesta esplicita e pianificata.
|
||||||
@@ -305,34 +308,34 @@ Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### DD-009 — Tesseramento CSI manuale in v1.1, integrazione API in v2.0
|
### DD-009 — Tesseramento CSI manuale, integrazione API in una fase successiva
|
||||||
|
|
||||||
**Data:** agosto 2026
|
**Data:** agosto 2026
|
||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
La v1.1 deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
|
Il modulo Profilo Giocatore deve aiutare gli admin a raccogliere documenti e dati per il tesseramento CSI. Un collegamento automatico al sistema CSI è complesso e non urgente.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
|
|
||||||
- **v1.1:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
|
- **Prima fase:** profilo completo, dashboard admin, download documenti, export CSV con i campi richiesti dal CSI.
|
||||||
- **v2.0:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
|
- **Fase successiva:** eventuale collegamento automatico a CSI (calendario, risultati, classifica ufficiale).
|
||||||
|
|
||||||
**Alternative scartate**
|
**Alternative scartate**
|
||||||
|
|
||||||
- Integrazione CSI già in v1.1 → scope troppo ampio, dipendenza da API esterne non controllate.
|
- Integrazione CSI già nella prima fase → scope troppo ampio, dipendenza da API esterne non controllate.
|
||||||
|
|
||||||
**Conseguenze**
|
**Conseguenze**
|
||||||
|
|
||||||
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
|
- Gli admin guadagnano subito tempo (niente più Excel e chat per i documenti).
|
||||||
- L’export CSV deve essere affidabile e completo: è il deliverable chiave della v1.1.
|
- L’export CSV deve essere affidabile e completo: è il deliverable chiave di questa prima fase.
|
||||||
|
|
||||||
**Riesame**
|
**Riesame**
|
||||||
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
|
Quando il CSI mette a disposizione API stabili o quando il volume di tesseramenti giustifica l’automazione.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### DD-010 — Profilo giocatore: niente storico certificati in v1
|
### DD-010 — Profilo giocatore: niente storico certificati
|
||||||
|
|
||||||
**Data:** agosto 2026
|
**Data:** agosto 2026
|
||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
@@ -341,7 +344,7 @@ Quando il CSI mette a disposizione API stabili o quando il volume di tesserament
|
|||||||
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
|
Il certificato medico va aggiornato ogni stagione. Tenere lo storico di tutte le versioni complica upload, storage e privacy.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
In v1 il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
|
Il giocatore può **sovrascrivere** certificato e data di scadenza. Lo storico delle versioni precedenti non viene conservato.
|
||||||
|
|
||||||
**Alternative scartate**
|
**Alternative scartate**
|
||||||
|
|
||||||
@@ -367,7 +370,7 @@ Se il CSI o il regolamento interno richiedono conservazione storica.
|
|||||||
Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
|
Oggi l’app identifica l’utente con la selezione del giocatore da una lista, senza login. Documenti, certificati e dati personali richiedono sapere _chi_ sta operando e impedire accessi non autorizzati.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
Prima di completare il modulo Profilo Giocatore (v1.1), introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
|
Prima di completare il modulo Profilo Giocatore, introdurre **login con Google o email** tramite Supabase Auth — non tramite Lovable Auth. Dopo il login, il giocatore associa il proprio profilo squadra.
|
||||||
|
|
||||||
**Alternative scartate**
|
**Alternative scartate**
|
||||||
|
|
||||||
@@ -385,7 +388,7 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### DD-012 — Non migrare gli ID giocatore in v1.1
|
### DD-012 — Rimandare la migrazione degli ID giocatore
|
||||||
|
|
||||||
**Data:** agosto 2026
|
**Data:** agosto 2026
|
||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
@@ -394,11 +397,11 @@ Dopo il rollout auth, se emergono problemi di adozione (giocatori poco digitali)
|
|||||||
L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
|
L’app usa identificativi semplici (`g1`, `g2`, …) collegati a presenze, voti, palloni e altre funzioni già in uso. Nel database esiste anche una tabella `giocatori` con UUID, non collegata al codice attuale.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
|
Per ora **non** unificare gli ID. I nuovi dati del profilo si agganciano agli identificativi già in uso. La migrazione verso UUID resta un lavoro separato, pianificato e testato.
|
||||||
|
|
||||||
**Alternative scartate**
|
**Alternative scartate**
|
||||||
|
|
||||||
- Migrare tutto a UUID in v1.1 → rischio alto di rompere presenze, voti, scout e notifiche.
|
- Migrare subito tutto a UUID → rischio alto di rompere presenze, voti, scout e notifiche.
|
||||||
|
|
||||||
**Conseguenze**
|
**Conseguenze**
|
||||||
|
|
||||||
@@ -406,7 +409,7 @@ Per la v1.1 **non** unificare gli ID. I nuovi dati del profilo si agganciano agl
|
|||||||
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
|
- `DATABASE.md` va tenuto aggiornato su cosa è “attivo” e cosa è “futuro”.
|
||||||
|
|
||||||
**Riesame**
|
**Riesame**
|
||||||
Quando la v1.1 è stabile e c’è tempo per una migration con checklist regressioni completa.
|
Quando il modulo Profilo Giocatore è stabile e c’è tempo per una migration con checklist regressioni completa.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -435,16 +438,16 @@ Se si adotta un servizio che viola questa regola.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### DD-016 — Schema dati Profilo Giocatore v1.1 (F0)
|
### DD-016 — Schema dati Profilo Giocatore (F0)
|
||||||
|
|
||||||
**Data:** agosto 2026
|
**Data:** agosto 2026
|
||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle v1.0 già operative.
|
La progettazione F0 del modulo Profilo Giocatore ha definito come persistere dati personali, documenti e certificati, in coesistenza con l’anagrafica attuale (`g1`…`g17` nel codice) e con la tabella `giocatori` UUID già presente ma non usata. Serviva una scelta chiara su dove salvare i dati, come collegare l’autenticazione e come proteggere documenti sensibili — senza toccare le tabelle preesistenti già operative.
|
||||||
|
|
||||||
**Decisione**
|
**Decisione**
|
||||||
Per la v1.1 si introducono **due nuove tabelle additive**:
|
Si introducono **due nuove tabelle additive**:
|
||||||
|
|
||||||
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
|
- **`giocatori_squadra`** — anagrafica squadra con ID testuali (`g1`…`g17`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento account (`auth_user_id`).
|
||||||
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
|
- **`profili_giocatore`** — dati personali, metadati documento identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`.
|
||||||
@@ -455,8 +458,8 @@ Regole vincolanti:
|
|||||||
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
|
2. L’associazione **`auth_user_id` ↔ giocatore** è un’operazione **controllata e atomica** (es. al primo accesso da `/benvenuto`, con `UPDATE … WHERE auth_user_id IS NULL`). Il giocatore **non può modificare liberamente** `auth_user_id`; solo un admin può resettarlo in casi eccezionali.
|
||||||
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
|
3. I file (documento identità, certificato, foto tessera) vivono nel bucket Storage **`profili-giocatore`**, configurato come **privato**.
|
||||||
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
|
4. Documenti personali e sanitari **non devono mai essere esposti tramite URL pubblici**. Accesso solo tramite client autenticato con policy RLS, o signed URL a scadenza breve per download admin.
|
||||||
5. Le **tabelle v1.0 esistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID in v1.1 (coerente con DD-012).
|
5. Le **tabelle preesistenti non vengono modificate** (`eventi_app`, `risposte_presenze`, voti, palloni, scout, push, ecc.). Il profilo si aggancia agli ID `g1`…`g17` già in uso, senza migrare verso UUID per ora (coerente con DD-012).
|
||||||
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo in v1.1.
|
6. La tabella `giocatori` (UUID) resta **invariata e non usata** dal modulo profilo.
|
||||||
|
|
||||||
**Alternative scartate**
|
**Alternative scartate**
|
||||||
|
|
||||||
@@ -464,21 +467,21 @@ Regole vincolanti:
|
|||||||
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
|
- Salvare file come base64 nel database → ingestibile, difficile da gestire e da scaricare.
|
||||||
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità.
|
- Bucket pubblico con URL permanenti → inaccettabile per dati sanitari e documenti d’identità.
|
||||||
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
|
- Permettere al giocatore di cambiare `auth_user_id` liberamente → rischio di impersonazione e race condition.
|
||||||
- Modificare tabelle v1.0 per aggiungere FK verso il profilo → viola DD-004 e DD-012.
|
- Modificare le tabelle preesistenti per aggiungere FK verso il profilo → viola DD-004 e DD-012.
|
||||||
|
|
||||||
**Conseguenze**
|
**Conseguenze**
|
||||||
|
|
||||||
- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
|
- Coesistono temporaneamente tre rappresentazioni dell’anagrafica: `crapp-data.ts` (fallback), `giocatori_squadra` (target), `giocatori` UUID (dormiente).
|
||||||
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
|
- `src/lib/rosa.ts` legge dal database e ricade su `crapp-data.ts` in caso di errore o assenza dati (DD-015).
|
||||||
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
|
- Il completamento profilo (30/30/30/10) si calcola in app, non si persiste nel database.
|
||||||
- Lo storico certificati non viene conservato in v1 (coerente con DD-010).
|
- Lo storico certificati non viene conservato (coerente con DD-010).
|
||||||
- Le migration M1–M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
|
- Le migration M1–M2 (tabelle, RLS, bucket) restano **additive**: solo `CREATE`, nessun `ALTER`/`DROP` su schema esistente.
|
||||||
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
|
- Raffina e attua quanto proposto in DD-015 per la rosa anagrafica, senza sostituire formalmente quella voce.
|
||||||
|
|
||||||
**Riesame**
|
**Riesame**
|
||||||
|
|
||||||
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
|
- Quando `giocatori_squadra` è stabile in produzione e il fallback `crapp-data.ts` non serve più.
|
||||||
- Quando si pianifica la convergenza verso UUID (DD-012, post v1.1).
|
- Quando si pianifica la convergenza verso UUID (DD-012, dopo che il profilo sarà stabile).
|
||||||
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
|
- Se il CSI o il regolamento richiedono conservazione storica documenti o consensi privacy dedicati.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -570,7 +573,7 @@ Unificare gradualmente sul modello autenticato, dopo auth e profilo stabili.
|
|||||||
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
|
Rischio regressioni su calendario e presenze, moduli più usati della squadra.
|
||||||
|
|
||||||
**Riesame previsto**
|
**Riesame previsto**
|
||||||
Post v1.1, con migration e test dedicati.
|
Dopo che il modulo Profilo Giocatore sarà stabile, con migration e test dedicati.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -759,7 +762,7 @@ spesso da rendere il tema scuro una funzione e non un vezzo.
|
|||||||
**Stato:** Accettata
|
**Stato:** Accettata
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
Le tabelle della v1.0 sono nate con policy `USING (true)` per `anon, authenticated`. M4
|
Le tabelle preesistenti sono nate con policy `USING (true)` per `anon, authenticated`. M4
|
||||||
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
|
(DD-011) ha tolto il GRANT ad `anon`, e la cosa è stata letta come «ora è chiuso». Non lo
|
||||||
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
|
era: per gli autenticati non c'era rimasto nessun limite. Verificato sul database locale con
|
||||||
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app` →
|
un utente appena creato, senza ruolo e senza slot nella rosa: `POST /rest/v1/eventi_app` →
|
||||||
@@ -970,3 +973,185 @@ prima di inviare.
|
|||||||
**Riesame**
|
**Riesame**
|
||||||
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
|
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
|
||||||
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
|
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
|
||||||
|
|
||||||
|
### DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
|
||||||
|
|
||||||
|
**Stato:** accettata · **Data:** 8 settembre 2026
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
|
||||||
|
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
|
||||||
|
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
|
||||||
|
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
|
||||||
|
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
|
||||||
|
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
|
||||||
|
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
|
||||||
|
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
|
||||||
|
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
|
||||||
|
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
|
||||||
|
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
|
||||||
|
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
|
||||||
|
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
|
||||||
|
convocazione o dopo la chiusura.
|
||||||
|
|
||||||
|
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
|
||||||
|
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
|
||||||
|
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
|
||||||
|
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
|
||||||
|
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
|
||||||
|
dall'audit che ha originato questa decisione.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
|
||||||
|
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
|
||||||
|
alla policy non-admin, li esclude naturalmente.
|
||||||
|
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
|
||||||
|
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
|
||||||
|
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
|
||||||
|
comportamento: tutta la rosa resta votabile, come prima.
|
||||||
|
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
|
||||||
|
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
|
||||||
|
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
|
||||||
|
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
|
||||||
|
davvero presente, non solo convocato, per MVP/pagelle/badge social.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
|
||||||
|
non solo in UI.
|
||||||
|
|
||||||
|
### DD-028 — Soglia minima di campione per Media voto e MVP in home
|
||||||
|
|
||||||
|
**Data:** 9 settembre 2026
|
||||||
|
**Stato:** Accettata
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
Un audit della sezione «Colpo d'occhio» in home (`index.tsx`, StatTile Presenze/Media
|
||||||
|
voto/MVP) ha trovato che due delle tre statistiche non avevano nessun minimo campionario:
|
||||||
|
`mediePagelle()` calcola una media aritmetica pura, così un giocatore con un solo voto da 10
|
||||||
|
mostrava "10" in home, più alto di un titolare con 40 voti e media 7.2 — lo stesso problema
|
||||||
|
che il badge Pagellone già risolve con `VOTI_MINIMI_PAGELLA` (badge.md), ma applicato solo al
|
||||||
|
badge, non alla StatTile home. Allo stesso modo `mvpVintiPerGiocatore()`/`vincitoriMvp()`
|
||||||
|
assegnavano un MVP di partita anche con un solo voto totale: bastava che un solo giocatore
|
||||||
|
votasse perché il votato "vincesse" nettamente, senza nessun quorum di partecipazione.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
|
||||||
|
- **Media voto** in home usa la stessa soglia del badge Pagellone: sotto `VOTI_MINIMI_PAGELLA`
|
||||||
|
(5) voti ricevuti, la StatTile mostra `—` invece della media, tramite la funzione pura
|
||||||
|
`mediaVotoColpoDOcchio()` (`pagelle.ts`), estratta dalla route per restare testabile (DD-020).
|
||||||
|
La funzione `mediePagelle()` non cambia: il filtro resta lato chiamante, come già faceva il
|
||||||
|
badge.
|
||||||
|
- **MVP**: `conteggioPartita`'s aggregazione, tramite `vincitoriMvp()` e
|
||||||
|
`mvpVintiPerGiocatore()`, richiede ora un quorum minimo di voti totali sulla partita
|
||||||
|
(`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`) prima di assegnare un vincitore, oltre alla regola già
|
||||||
|
esistente del margine netto tra primo e secondo. Un solo voto non basta più a incoronare
|
||||||
|
nessuno, nemmeno in assenza di concorrenza.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Alzare la soglia MVP oltre 2 (es. metà dei convocati) → serve conoscere i convocati
|
||||||
|
dell'evento dentro una funzione che oggi lavora solo sui voti; complessità non giustificata
|
||||||
|
per il gap trovato in audit.
|
||||||
|
- Lasciare l'MVP senza quorum e limitarsi al fix della Media voto → il problema di fondo
|
||||||
|
(un numero esiguo di voti che decide una statistica mostrata come solida) resterebbe aperto
|
||||||
|
per l'MVP.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- Alcuni MVP di partita già assegnati con un solo voto totale non contano più nel conteggio
|
||||||
|
`mvp` del giocatore: è una modifica retroattiva al dato mostrato, non solo al calcolo futuro,
|
||||||
|
perché `mvpVintiPerGiocatore()` deriva sempre il conteggio dai voti grezzi, senza storico
|
||||||
|
persistito a parte.
|
||||||
|
- `mediePagelle()` resta invariata: chi la chiama altrove (profilo, squadra) senza applicare la
|
||||||
|
soglia continua a mostrare la media grezza — non tocca questa decisione, resta il limite già
|
||||||
|
noto in [pagelle.md](modules/pagelle.md).
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se la squadra segnala che il quorum di 2 voti per l'MVP è troppo permissivo o troppo severo, o
|
||||||
|
se si vuole applicare la stessa soglia di Media voto anche alle StatTile di profilo e squadra.
|
||||||
|
|
||||||
|
### DD-029 — Cancellare un evento pulisce a cascata i dati collegati
|
||||||
|
|
||||||
|
**Data:** 9 settembre 2026
|
||||||
|
**Stato:** Accettata
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
|
||||||
|
nessuna pulizia quando un evento viene cancellato: sono ricalcolati a runtime sull'elenco
|
||||||
|
eventi corrente (`obiettivi.ts`), quindi un evento sparito da `eventi_app` semplicemente
|
||||||
|
smette di contare. Il problema è un livello sotto: `useEliminaEvento()`
|
||||||
|
(`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`. Nessuna delle otto tabelle
|
||||||
|
collegate (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`,
|
||||||
|
`badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) ha mai
|
||||||
|
avuto una foreign key verso `eventi_app(id)`: le loro righe restavano orfane a database.
|
||||||
|
|
||||||
|
Oggi è innocuo per le statistiche, perché nessun calcolo legge quelle tabelle se non partendo
|
||||||
|
dall'elenco eventi corrente. Ma è un rischio latente: un id evento futuro identico a uno
|
||||||
|
passato (generato come `"e" + timestamp`, collisione improbabile ma non impossibile)
|
||||||
|
erediterebbe dati vecchi non suoi; e un calcolo futuro che iterasse direttamente una di quelle
|
||||||
|
tabelle, invece di partire da `eventi_app`, conterebbe anche le righe orfane.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
Migration `m14_pulizia_dati_evento_cancellato`: un trigger `AFTER DELETE ON eventi_app`
|
||||||
|
cancella a cascata le righe corrispondenti (`evento_id`/`match_id = id evento cancellato`)
|
||||||
|
nelle otto tabelle collegate, tramite una funzione `SECURITY DEFINER`. Agisce a database, non
|
||||||
|
in `useEliminaEvento()`: protegge anche chi cancella un evento scrivendo direttamente su
|
||||||
|
PostgREST, non solo chi passa dal bottone dell'app.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Foreign key con `ON DELETE CASCADE` verso `eventi_app(id)` → non applicabile subito:
|
||||||
|
`mvp.md` documenta che storicamente `match_id` in `mvp_voti`/`pagelle_voti`/
|
||||||
|
`badge_social_voti` a volte conteneva l'id di una sessione Scout o di una partita CSI, non
|
||||||
|
l'id evento CrAPP. Un vincolo FK avrebbe rifiutato la migration alla prima riga storica
|
||||||
|
disallineata; il trigger non valida i dati esistenti, solo le cancellazioni da qui in avanti.
|
||||||
|
- Cancellazione manuale nelle otto tabelle dentro `useEliminaEvento()` → fragile: va tenuta
|
||||||
|
aggiornata a mano ogni volta che un nuovo modulo aggiunge una tabella con `evento_id`, e non
|
||||||
|
protegge chi scrive/cancella direttamente su PostgREST.
|
||||||
|
- Bonificare anche le righe orfane già esistenti nella stessa migration → rimandato: tocca dati
|
||||||
|
reali già scritti, è un intervento più delicato che merita una migration a sé, non urgente
|
||||||
|
perché quelle righe sono già invisibili a ogni calcolo attuale.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- Da questa migration in poi, cancellare un evento (da qualunque punto, app o REST diretto)
|
||||||
|
ripulisce automaticamente tutte le tabelle collegate.
|
||||||
|
- Le righe orfane generate da cancellazioni **precedenti** a questa migration restano nel
|
||||||
|
database: il trigger previene il problema da qui in avanti, non ripulisce lo storico.
|
||||||
|
- `test/integration/pulizia-evento.test.ts` è la definizione eseguibile del comportamento:
|
||||||
|
scrive una riga in ciascuna delle otto tabelle, cancella l'evento e verifica che spariscano
|
||||||
|
tutte.
|
||||||
|
|
||||||
|
**Aggiornamento (9 settembre 2026, stesso giorno)** — la bonifica dello storico prevista sopra
|
||||||
|
come "riesame" è stata fatta subito dopo, migration `m15_bonifica_dati_evento_orfani`: righe
|
||||||
|
orfane in `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`,
|
||||||
|
`scout_live`, `scout_partite` identificate confrontando `evento_id` con `eventi_app`. Per
|
||||||
|
`mvp_voti`/`pagelle_voti`/`badge_social_voti` il confronto si applica **solo** ai `match_id`
|
||||||
|
nel formato id evento CrAPP (`^e[0-9a-z]+$`, quello di `nuovoIdEvento()`): i vecchi voti su id
|
||||||
|
Scout (prefisso `s` + timestamp decimale) o CSI (numerico o `data-squadra-squadra`) non sono
|
||||||
|
orfani, sono dati storici legittimi mai collegati a un evento CrAPP (vedi contesto sopra), e la
|
||||||
|
migration non li tocca. Verificato manualmente sul database locale prima di applicarla: un voto
|
||||||
|
di test su id Scout è sopravvissuto alla bonifica, un voto di test su id evento CrAPP orfano è
|
||||||
|
stato rimosso.
|
||||||
|
|
||||||
|
**Aggiornamento (9 settembre 2026)** — la verifica manuale di M15 non lasciava nessuna rete di
|
||||||
|
sicurezza automatica per il futuro, a differenza del resto del progetto (DD-020). Migration
|
||||||
|
`m16_funzione_bonifica_dati_evento_orfani` rende lo stesso corpo una funzione
|
||||||
|
`bonifica_dati_evento_orfani()` (riservata al `service_role`, non richiamabile dall'app),
|
||||||
|
coperta da `test/integration/bonifica-evento.test.ts`: inserisce una riga orfana e una storica
|
||||||
|
su id Scout/CSI, richiama la funzione via RPC e verifica che tocchi solo la prima. Non serve
|
||||||
|
richiamarla di nuovo ora (M15 ha già ripulito lo storico): resta pronta come intervento di
|
||||||
|
manutenzione se in futuro il trigger di M14 smettesse di funzionare o emergesse un altro batch
|
||||||
|
di orfani.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va
|
||||||
|
aggiornata a mano, non c'è un meccanismo che lo forzi).
|
||||||
|
|||||||
+10
-4
@@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q
|
|||||||
|
|
||||||
## Fatto
|
## Fatto
|
||||||
|
|
||||||
Tutto quello che è in `main` e finirà nella prima release.
|
Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`).
|
||||||
|
|
||||||
- [x] Gestione squadra
|
- [x] Gestione squadra
|
||||||
- [x] Calendario
|
- [x] Calendario
|
||||||
@@ -16,17 +16,23 @@ Tutto quello che è in `main` e finirà nella prima release.
|
|||||||
- [x] Badge
|
- [x] Badge
|
||||||
- [x] Badge social
|
- [x] Badge social
|
||||||
- [x] Pagelle
|
- [x] Pagelle
|
||||||
|
- [x] Votazione MVP
|
||||||
- [x] Obiettivi di squadra
|
- [x] Obiettivi di squadra
|
||||||
|
- [x] Turno palloni
|
||||||
|
- [x] Infortuni — conteggio eventi saltati, in forma minima
|
||||||
- [x] Notifiche Push (promemoria intelligenti)
|
- [x] Notifiche Push (promemoria intelligenti)
|
||||||
- [x] Dashboard amministratore
|
- [x] Dashboard amministratore
|
||||||
- [x] Download CSV dati
|
- [x] Download CSV dati
|
||||||
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
|
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||||
certificati resta un'estensione futura
|
(caricamento, scadenza, stato, download) e foto tessera; lo storico dei certificati
|
||||||
|
resta un'estensione futura
|
||||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||||
tesserato (numero e data di tessera)
|
tesserato (numero e data di tessera)
|
||||||
- [x] Collegamento CSI (stagione 2025/26)
|
- [x] Collegamento CSI (stagione 2025/26)
|
||||||
- [x] Classifica automatica
|
- [x] Classifica automatica (campionato e Coppa)
|
||||||
- [x] Risultati campionato
|
- [x] Risultati campionato
|
||||||
|
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||||
|
calcolata dal CSI
|
||||||
|
|
||||||
## Prossimo
|
## Prossimo
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
||||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
|
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle preesistenti.
|
||||||
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
||||||
-- `profili-giocatore` creato dalla migration M3.
|
-- `profili-giocatore` creato dalla migration M3.
|
||||||
|
|
||||||
|
|||||||
+400
-8
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Badge
|
# Modulo — Badge
|
||||||
|
|
||||||
**Stato:** implementato (v1.0), coerente con DD-007 e DD-008
|
**Stato:** implementato, coerente con DD-007 e DD-008
|
||||||
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
|
**File principali:** `src/lib/badges.ts`, `src/lib/badge-social.ts`,
|
||||||
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
|
`src/components/crapp/CollezioneBadge.tsx`, `src/components/crapp/BadgeDrawer.tsx`,
|
||||||
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
|
`src/components/crapp/CelebrazioneBadge.tsx`, `src/components/crapp/VotoSocial.tsx`
|
||||||
@@ -26,10 +26,30 @@ badge assegnati per voto dai compagni.
|
|||||||
## Implementazione
|
## Implementazione
|
||||||
|
|
||||||
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
|
- `badgeDefs`/`badgeSegreti` (`badges.ts`) definiscono ogni badge con una funzione
|
||||||
`valore(g)` e soglie bronzo/argento/oro. Le fonti dato sono solo statistiche indipendenti
|
`valore(g)` e tre soglie bronzo/argento/oro (elenco completo con fonte e soglie di ognuno in
|
||||||
dal ruolo in campo: MVP, media pagelle, turni palloni, presenze, serie, infortuni,
|
"Elenco badge" sotto). Il grado è calcolato da `gradoRaggiunto()`/`statoBadge()`: soglie
|
||||||
ritardi, cacche — **mai** punti/ace/muri dello Scout Live, coerentemente con DD-008.
|
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
|
||||||
- I badge segreti restano nascosti (icona lucchetto) finché non sbloccati.
|
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
|
||||||
|
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
|
||||||
|
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
|
||||||
|
Pagellone, lo Sherpa dei palloni, l'MVP e i badge social.
|
||||||
|
- **Badge Sherpa dei palloni** (`palloni`, in `badgeDefs`): `g.palloni` non è un contatore
|
||||||
|
incrementato a ogni evento, ma ricalcolato da `conteggioTurni()` (`palloni-core.ts`) su
|
||||||
|
`Giocatore.palloni` (`rosa.ts`) — meccanismo di turni/rotazione descritto per intero in
|
||||||
|
[palloni.md](palloni.md), non ripetuto qui. `rosa.ts` passa a `conteggioTurni()` **solo i
|
||||||
|
turni confermati** (`turniSalvati` da `useTurniPalloni()`), non l'output di
|
||||||
|
`completaTurni()`: le proposte automatiche di rotazione (usate altrove, per la UI di
|
||||||
|
`TurnoPalloni.tsx`) non contano per il badge, che premia solo chi ha davvero confermato di
|
||||||
|
aver portato i palloni. Conta solo per eventi già trascorsi (`e.data < oggi`, stesso
|
||||||
|
criterio delle presenze).
|
||||||
|
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
|
||||||
|
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
|
||||||
|
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
|
||||||
|
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
|
||||||
|
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
|
||||||
|
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
|
||||||
|
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
|
||||||
|
stessa `mediePagelle()`.
|
||||||
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
|
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
|
||||||
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
|
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
|
||||||
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
|
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
|
||||||
@@ -37,6 +57,14 @@ badge assegnati per voto dai compagni.
|
|||||||
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
|
database (vincolo `badge_social_no_autovoto`, migration `m12_niente_autovoto`). A
|
||||||
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
|
differenza delle [Pagelle](pagelle.md), qui non c'è alcun tentativo di anonimato:
|
||||||
`votante_id`/`votato_id` sono entrambi visibili.
|
`votante_id`/`votato_id` sono entrambi visibili.
|
||||||
|
- **Badge MVP** (`mvp`, in `badgeDefs`): l'unico badge normale la cui fonte non è un contatore
|
||||||
|
già pronto ma il risultato della votazione MVP tra compagni — meccanismo di voto (chi vota
|
||||||
|
chi, apertura, autovoto, RLS) descritto per intero in [mvp.md](mvp.md), non ripetuto qui.
|
||||||
|
Quello che serve per capire il badge: `mvpVintiPerGiocatore()` (`mvp-voti.ts`) conta, per
|
||||||
|
ogni giocatore, quante partite ha vinto con un **vantaggio netto** sul secondo (non il
|
||||||
|
totale dei voti ricevuti; in caso di parità la partita non conta per nessuno). `rosa.ts`
|
||||||
|
(`useRosa()`) scrive quel numero in `Giocatore.mvp`, che `badgeDefs` legge con
|
||||||
|
`valore: (g) => g.mvp` e confronta con le soglie 1/3/5 (bronzo/argento/oro).
|
||||||
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
- `CollezioneBadge.tsx` mostra sbloccati, in progresso, badge social vinti e un contatore di
|
||||||
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
|
badge segreti ancora da scoprire; `BadgeDrawer.tsx` il dettaglio di un singolo badge;
|
||||||
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
|
`CelebrazioneBadge.tsx` l'overlay celebrativo alla prima visualizzazione di un badge nuovo.
|
||||||
@@ -46,6 +74,61 @@ badge assegnati per voto dai compagni.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Elenco badge
|
||||||
|
|
||||||
|
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||||
|
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
|
||||||
|
informazioni verso l'interfaccia.
|
||||||
|
|
||||||
|
### Badge normali (gradi bronzo/argento/oro)
|
||||||
|
|
||||||
|
Tutti calcolati come `valore(g)` confrontato con tre soglie crescenti; il grado è l'ultima
|
||||||
|
soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
||||||
|
|
||||||
|
| id | nome | come si guadagna | soglie B/A/O |
|
||||||
|
| ------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||||
|
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
|
||||||
|
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
|
||||||
|
| `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 |
|
||||||
|
| `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)
|
||||||
|
|
||||||
|
Stesso motore dei normali ma con soglie `{bronzo:1, argento:1, oro:1}`: `valore(g)` è 0 o 1,
|
||||||
|
quindi il badge è "trovato o no", mai graduato. In UI compaiono con icona lucchetto finché non
|
||||||
|
sbloccati. Attenzione se si tocca `gradoRaggiunto()`: con le tre soglie tutte uguali a 1, il
|
||||||
|
grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo che il ciclo
|
||||||
|
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||||
|
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||||
|
|
||||||
|
| 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-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-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`) |
|
||||||
|
|
||||||
|
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||||
|
|
||||||
|
Non hanno gradi: si "vince" o non si vince una categoria in una partita. `vincitoreCategoria()`
|
||||||
|
richiede un vantaggio netto sul secondo classificato, in parità nessun vincitore.
|
||||||
|
`badgeSocialVinti()` conta quante partite ha vinto ciascun giocatore in ogni categoria (non i
|
||||||
|
voti ricevuti).
|
||||||
|
|
||||||
|
| id | nome | cosa premia |
|
||||||
|
| ------------ | -------------------------- | ------------------------------------------------ |
|
||||||
|
| `affidabile` | Compagno affidabile | sempre presente, sempre sul pezzo |
|
||||||
|
| `spirito` | Miglior spirito di squadra | carica il gruppo dal primo all'ultimo punto |
|
||||||
|
| `fairplay` | Fair play | rispetto per compagni, avversari e arbitro |
|
||||||
|
| `meme` | Meme della partita | la scena più memorabile della partita |
|
||||||
|
| `cuore` | Cuore del gruppo | chi tiene unita la squadra anche fuori dal campo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Regole rispettate
|
## Regole rispettate
|
||||||
|
|
||||||
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
|
- **DD-007**: nessuna tabella `badge_sbloccati`, tutto calcolato a runtime dai dati
|
||||||
@@ -55,6 +138,284 @@ badge assegnati per voto dai compagni.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Copertura test
|
||||||
|
|
||||||
|
Verifica badge per badge (fatta rileggendo codice e test riga per riga, non solo per
|
||||||
|
categoria). Due bug trovati in una sessione di audit dedicata su tutti i 16 badge (dettagli
|
||||||
|
nelle sezioni sotto e in "Problemi noti"): `s-tiebreak` non applicava la soglia minima di voti
|
||||||
|
di Pagellone (**corretto**), `s-cacche` prometteva "partite di campionato" senza che il codice
|
||||||
|
lo verificasse mai (**la descrizione è stata corretta**, il comportamento — qualunque partita
|
||||||
|
conta — era già quello voluto). Tutti e 16 i badge hanno ora copertura unit **e** integration
|
||||||
|
end-to-end completa.
|
||||||
|
|
||||||
|
**Badge normali** — `badges.ts` testa la propria funzione pura (soglia → grado,
|
||||||
|
`badges.test.ts`) sull'output di altri moduli:
|
||||||
|
|
||||||
|
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
|
||||||
|
`badges.test.ts:44-48`), progresso a metà (`:52-56`).
|
||||||
|
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
|
||||||
|
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
|
||||||
|
bronzo". Più la soglia minima di voti (vedi sotto).
|
||||||
|
- `palloni`: soglie 3/6/10 testate esplicitamente (bronzo/argento/oro, confine incluso e
|
||||||
|
oltre l'oro resta oro, `badges.test.ts:94-104`), oltre a un caso di progresso non tondo
|
||||||
|
(5/6 → 83%). Pipeline end-to-end sotto, come `mvp`/`pagella`.
|
||||||
|
- `presenze`: soglie 5/15/30 testate esplicitamente (confine incluso, oltre l'oro resta oro,
|
||||||
|
`badges.test.ts:108-116`). Pipeline end-to-end sotto, come `mvp`/`pagella`/`palloni`.
|
||||||
|
- `serie-allenamenti`: soglie 3/6/10 testate esplicitamente (confine incluso, oltre l'oro
|
||||||
|
resta oro, `badges.test.ts:118-126`). Pipeline end-to-end sotto, come gli altri badge da
|
||||||
|
tabella.
|
||||||
|
- `serie-conferme`: soglie 3/8/15 testate esplicitamente (confine incluso, oltre l'oro resta
|
||||||
|
oro, `badges.test.ts:128-136`), oltre agli invarianti generali e a
|
||||||
|
`collezioneBadge`/`prossimoTraguardo` con valori al massimo. Pipeline end-to-end sotto, come
|
||||||
|
gli altri badge da tabella.
|
||||||
|
|
||||||
|
`mvp`, `pagella`, `palloni`, `presenze`, `serie-allenamenti` e `serie-conferme` sono le
|
||||||
|
eccezioni con integration dedicato (sotto) perché la loro fonte passa da una tabella di
|
||||||
|
voto/turni/presenze
|
||||||
|
letta e ricalcolata dal vivo, non da un contatore già pronto altrove.
|
||||||
|
|
||||||
|
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto:
|
||||||
|
`s-tiebreak` (mvp:1 non basta, mediaVoto 7.9 non basta, sotto `VOTI_MINIMI_PAGELLA` voti non
|
||||||
|
basta nemmeno con media alta — vedi il bug fix sotto), `s-mai-forfait` (ogni soglia isolata al
|
||||||
|
confine, non solo "entrambe servono"), `s-infermeria` (2 infortuni non bastano), `s-ritardi` (4
|
||||||
|
ritardi non bastano — gap colmato in questa sessione), `s-cacche` (2 cacche non bastano).
|
||||||
|
Copertura unit completa **e** integration dedicato per tutti e 5 (aggiunto in questa sessione,
|
||||||
|
vedi sotto): i dati sorgente hanno già i propri test di integrazione nei rispettivi moduli, ma
|
||||||
|
nessuno prima arrivava fino a `statoBadge()` sul segreto stesso con dati scritti a database.
|
||||||
|
|
||||||
|
**Badge MVP — pipeline end-to-end** (aggiunta in una sessione dedicata a completare la
|
||||||
|
copertura di questo badge):
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts` (soglie/gradi) + `mvp-voti.test.ts` (conteggio partita, vincitore con
|
||||||
|
vantaggio netto, parità che non assegna, apertura voto 2h dopo il fischio d'inizio).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `scritture.test.ts` — semantica dell'`upsert` di `mvp_voti` (un voto per
|
||||||
|
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
|
||||||
|
(`mvp_no_autovoto`).
|
||||||
|
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
|
||||||
|
si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
|
||||||
|
votato non convocati vengono rifiutati.
|
||||||
|
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
|
||||||
|
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
|
||||||
|
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
|
||||||
|
deve contare come vittoria.
|
||||||
|
|
||||||
|
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
|
||||||
|
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
|
||||||
|
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
|
||||||
|
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
|
||||||
|
(`pagelle_no_autovoto`), già presente prima di questa sessione.
|
||||||
|
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
|
||||||
|
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
|
||||||
|
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
|
||||||
|
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
|
||||||
|
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
|
||||||
|
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
|
||||||
|
applichi le soglie normali sopra soglia.
|
||||||
|
|
||||||
|
**Badge Sherpa dei palloni — pipeline end-to-end, ora senza contare le proposte non
|
||||||
|
confermate** (analisi dedicata: trovato e sistemato il gap "le proposte contano", che
|
||||||
|
gonfiava il badge di turni mai confermati da nessuno — vedi "Problemi noti da sistemare"):
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts:93-104` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||||
|
`palloni-core.test.ts`, già completo prima di questa sessione (`completaTurni()`,
|
||||||
|
`conteggioTurni()`, rotazione bilanciata su un giro completo di partite, allenamenti mai
|
||||||
|
proposti in automatico, turno di un giocatore non più in rosa che non rompe il conteggio).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `scritture.test.ts` — un turno resta uno per evento (l'upsert sostituisce, non aggiunge).
|
||||||
|
- `palloni-badge.test.ts` — end-to-end reale: scrive eventi e turni **solo parzialmente
|
||||||
|
confermati** su `eventi_app`/`turni_palloni`, rilegge via REST come fa `fetchTurni()`/
|
||||||
|
`daRiga()` e passa `turniSalvati` (solo confermati, mai l'output di `completaTurni()`) a
|
||||||
|
`conteggioTurni()` fino a `statoBadge()`: dimostra che un evento passato senza turno
|
||||||
|
confermato **non conta per nessuno**, anche se un algoritmo di rotazione (usato altrove
|
||||||
|
per la UI) lo proporrebbe automaticamente; verifica anche che un evento futuro non conti,
|
||||||
|
pur avendo già una conferma.
|
||||||
|
|
||||||
|
**Badge Presenza fissa — pipeline end-to-end** (analisi dedicata: nessun bug trovato; a
|
||||||
|
differenza di MVP/pagelle/badge social, per questo badge **non serve** l'estensione RLS di
|
||||||
|
M13 — vedi sotto):
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts:108-116` — soglie 5/15/30 (confine incluso, oltre l'oro resta oro) +
|
||||||
|
`presenze.test.ts`, già molto completo prima di questa sessione (`contaPresenzeGiocatore()`
|
||||||
|
con ritardo che conta come presenza, denominatore uguale per tutti, eventi futuri esclusi,
|
||||||
|
filtro sui convocati, solo partite/allenamenti).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `obiettivi.test.ts` — copre già `contaPresenzeGiocatore()` end-to-end per l'obiettivo
|
||||||
|
"250 presenze complessive" (o3), la stessa funzione usata dal badge.
|
||||||
|
- `presenze-badge.test.ts` (nuovo) — end-to-end reale sul badge: scrive eventi e risposte
|
||||||
|
su `eventi_app`/`risposte_presenze`, rilegge via REST come fa `fetchPresenze()`/`daRiga()`
|
||||||
|
e verifica che `statoBadge()` attraversi le tre soglie con dati veri (incluso un ritardo
|
||||||
|
che conta come presenza e un'assenza che non conta). Dimostra anche che una risposta
|
||||||
|
scritta per un evento senza convocazione **non conta comunque**, perché
|
||||||
|
`contaPresenzeGiocatore()` filtra già per `convocati` lato applicazione — a differenza di
|
||||||
|
MVP/pagelle/badge social, qui non serve una policy RLS aggiuntiva: il filtro è nella
|
||||||
|
funzione pura che il badge consuma, non solo in UI.
|
||||||
|
|
||||||
|
**Badge Sempre in palestra — pipeline end-to-end** (analisi dedicata: nessun bug trovato).
|
||||||
|
Stessa fonte dati di `presenze` (`risposte_presenze`) ma logica diversa: non un totale, una
|
||||||
|
**serie consecutiva** che un buco azzera e un infortunio congela. Anche qui, come per
|
||||||
|
`presenze`, non serve nessuna estensione RLS: il filtro sui convocati è già nella funzione
|
||||||
|
pura.
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts:118-126` — soglie 3/6/10 (confine incluso, oltre l'oro resta oro) +
|
||||||
|
`presenze.test.ts`, già completo prima di questa sessione su `serieConsecutiva()` (buco che
|
||||||
|
azzera, infortunio che congela invece di azzerare, nessuna risposta vale come buco,
|
||||||
|
convocati che non spezzano la serie di chi non era coinvolto).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `serie-allenamenti-badge.test.ts` (nuovo) — end-to-end reale: scrive allenamenti e
|
||||||
|
risposte su `eventi_app`/`risposte_presenze`, rilegge via REST e verifica che
|
||||||
|
`statoBadge()` attraversi bronzo/argento/oro con presenze consecutive vere, che
|
||||||
|
un'assenza dopo 10 presenze di fila azzeri tutto (torna a nessun grado), e — separatamente
|
||||||
|
— che un infortunio **non** azzeri la serie ma la lasci congelata (3 presenze vere,
|
||||||
|
un infortunio nel mezzo saltato dal conteggio, poi ancora presente: la serie resta a 3,
|
||||||
|
non riparte da 1).
|
||||||
|
|
||||||
|
**Badge Risposta lampo — pipeline end-to-end** (analisi dedicata: nessun bug trovato nella
|
||||||
|
logica di calcolo; l'unico limite è quello già noto e documentato sui dati pre-`m9`, vedi
|
||||||
|
"Limiti noti"). Il badge dipende da `serieConferme()`, che passa da due colonne facili da
|
||||||
|
confondere fra loro (`creato_il`/`risposto_il`, vedi [serie-presenze.md](serie-presenze.md)):
|
||||||
|
un integration test aggiunto per verificare che la mappatura verso `creatoIl`/`tempi` regga con
|
||||||
|
dati reali, non solo con timestamp scelti a mano — cosa che i test unitari, che non toccano il
|
||||||
|
database, non possono garantire.
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts:128-136` — soglie 3/8/15 (confine incluso, oltre l'oro resta oro) +
|
||||||
|
`presenze.test.ts`, esteso in questa sessione su `serieConferme()`: oltre al buco che azzera
|
||||||
|
e all'evento senza `creatoIl` che viene saltato (già presenti), ora anche un evento convocato
|
||||||
|
solo per un altro giocatore che non spezza la serie, partite e allenamenti sommati nella
|
||||||
|
stessa serie, il confronto inclusivo esattamente a 24h (dentro conta, un secondo oltre
|
||||||
|
azzera), e un evento futuro che non entra ancora nel calcolo.
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `serie-conferme-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il`
|
||||||
|
esplicito e risposte con `risposto_il` esplicito su `eventi_app`/`risposte_presenze`,
|
||||||
|
rilegge via REST come fa `daRiga()`/`fetchPresenze()` e verifica che `statoBadge()`
|
||||||
|
attraversi bronzo/argento/oro con conferme rapide vere, che una risposta arrivata oltre le
|
||||||
|
24h azzeri tutto anche dopo 15 conferme di fila, e — separatamente — che partite e
|
||||||
|
allenamenti si sommino nella stessa serie senza bisogno di un filtro per tipo.
|
||||||
|
|
||||||
|
**Badge Cliente VIP dell'Infermeria e Aspettate, arrivo! — pipeline end-to-end** (analisi
|
||||||
|
dedicata: nessun bug trovato). Stessa fonte (`contaInfortuni()`/`contaRitardi()` in
|
||||||
|
`src/lib/infortuni.ts`, entrambe sopra la stessa `contaStato()` privata) e stessa struttura di
|
||||||
|
`serie-allenamenti`/`serie-conferme`, ma senza serie: un contatore semplice di eventi passati.
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts` (soglie 3 e 5, confine appena sotto) + `infortuni.test.ts`, esteso in
|
||||||
|
questa sessione con un giocatore che ha **sia** un infortunio **sia** un ritardo (su eventi
|
||||||
|
diversi): i due conteggi restano indipendenti, nessuno "ruba" voci all'altro.
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `s-infermeria-badge.test.ts` / `s-ritardi-badge.test.ts` (nuovi) — end-to-end reali: scrivono
|
||||||
|
eventi e risposte "infortunato"/"ritardo" su `eventi_app`/`risposte_presenze`, rileggono via
|
||||||
|
REST e verificano che il segreto resti bloccato appena sotto soglia e si sblocchi
|
||||||
|
esattamente al confine (3 infortuni, 5 ritardi).
|
||||||
|
|
||||||
|
**Badge Trono di ferro — pipeline end-to-end, descrizione corretta** (analisi dedicata: trovato
|
||||||
|
un disallineamento fra descrizione e codice, **risolto aggiornando il testo**, non la logica —
|
||||||
|
vedi "Problemi noti" più sotto per il perché). `statisticheCacche()` (`src/lib/cacche.ts`) non
|
||||||
|
ha mai distinto partite di campionato da amichevoli: contava (e conta ancora) qualunque partita
|
||||||
|
con 3+ cacche dichiarate. La vecchia descrizione del badge prometteva "partite di campionato",
|
||||||
|
cosa che il codice non ha mai verificato — corretta in "partite (campionato o amichevole)".
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts` (soglia 3, confine appena sotto — gap colmato in questa sessione) +
|
||||||
|
`cacche.test.ts` (già completo su `giornateTop`).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `s-cacche-badge.test.ts` (nuovo) — end-to-end reale: scrive 2 giornate da record su partite
|
||||||
|
di campionato e una su un'amichevole, dimostrando con dati veri che l'amichevole conta
|
||||||
|
esattamente come le altre — pin del comportamento attuale, così chi in futuro reintroduce un
|
||||||
|
filtro sul campionato deve accorgersene qui, non scoprirlo in produzione.
|
||||||
|
|
||||||
|
**Badge Uomo tie-break — pipeline end-to-end, bug corretto** (analisi dedicata: trovato e
|
||||||
|
sistemato il gap "un voto pagella solo sblocca il segreto insieme a 2 MVP"). Il segreto usa
|
||||||
|
`g.mediaVoto`, lo stesso campo del badge normale `pagella` — che però lo azzera sotto
|
||||||
|
`VOTI_MINIMI_PAGELLA` (5) voti ricevuti, proprio per evitare che un singolo voto sblocchi/tolga
|
||||||
|
il badge senza significatività statistica. `s-tiebreak` non applicava lo stesso filtro: ora sì
|
||||||
|
(`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`).
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts` — sotto la soglia minima di voti il segreto resta bloccato anche con
|
||||||
|
media 8 e 2 MVP; un solo MVP non basta (isolato dal resto).
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `s-tiebreak-badge.test.ts` (nuovo) — end-to-end reale: scrive voti MVP e pagella veri,
|
||||||
|
dimostra che un solo voto pagella (media alta, 2 MVP) NON sblocca il segreto, e che il quinto
|
||||||
|
voto lo sblocca — il fix verificato con la stessa pipeline `mvp_voti`/`pagelle_voti` → REST →
|
||||||
|
`mvpVintiPerGiocatore()`/`mediePagelle()` → `statoBadge()` che userebbe l'app.
|
||||||
|
|
||||||
|
**Badge Mai un forfait — pipeline end-to-end** (analisi dedicata: nessun bug trovato). Unico
|
||||||
|
segreto a combinare due statistiche indipendenti (`serieConferme()` e
|
||||||
|
`contaPresenzeGiocatore()`), entrambe già testate a fondo nei rispettivi moduli.
|
||||||
|
|
||||||
|
- Unit: `badges.test.ts`, esteso in questa sessione con ogni soglia isolata al confine
|
||||||
|
(`serieConferme` appena sotto con `presenze` abbondanti, e viceversa), non solo "insieme non
|
||||||
|
bastano".
|
||||||
|
- Integration (`npx supabase start` richiesto):
|
||||||
|
- `s-mai-forfait-badge.test.ts` (nuovo) — end-to-end reale: scrive eventi con `creato_il` e
|
||||||
|
risposte con `risposto_il` veri, verifica che il segreto resti bloccato a 9/9 e si sblocchi a
|
||||||
|
15/15, e che una risposta lenta azzeri la serie di conferme **senza** azzerare le presenze
|
||||||
|
già accumulate (le due statistiche restano indipendenti anche a database).
|
||||||
|
|
||||||
|
**Badge social** — nessuna delle 5 categorie ha logica _propria_ nel codice: l'id è solo una
|
||||||
|
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
|
||||||
|
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
|
||||||
|
meccanismo:
|
||||||
|
|
||||||
|
- Unit (`badge-social.test.ts`): conteggio isolato per match+categoria (`:29-32`), vantaggio
|
||||||
|
netto/parità → nessun vincitore (`:38-41`), vittorie multi-partita (`badgeSocialVinti`, g2
|
||||||
|
vince in `m1` e `m2` → `{affidabile: 2}`, `:48`), zero voti → zero badge (`:51`). Estesi in
|
||||||
|
questa sessione: un voto totale solo basta a vincere, una parità a 3 candidati (i primi due
|
||||||
|
pari, il terzo staccato) resta senza vincitore, categorie diverse nella stessa partita non si
|
||||||
|
mischiano in `badgeSocialVinti()`.
|
||||||
|
- Integration: upsert/sostituzione voto per categoria (`scritture.test.ts:170-202`), autovoto
|
||||||
|
rifiutato — doppia barriera UI + database (`scritture.test.ts:124-148`), RLS `m11` — un
|
||||||
|
giocatore firma solo il proprio voto (`permessi.test.ts:344-369`).
|
||||||
|
- `badge-social.test.ts` (nuovo, in `test/integration/`) — end-to-end reale sulle **5
|
||||||
|
categorie effettive** di `categorieSocial` (non più solo 2-3, e non più le categorie
|
||||||
|
inventate di `scritture.test.ts`): scrive voti veri su `badge_social_voti`, dimostra che
|
||||||
|
tutte e 5 si contano e si vincono allo stesso modo, e che una parità su una categoria non
|
||||||
|
tocca il conteggio delle altre 4 nella stessa partita.
|
||||||
|
|
||||||
|
### Riepilogo per badge
|
||||||
|
|
||||||
|
| # | id | tipo | test unit | test integration |
|
||||||
|
| --- | ------------------- | ------- | ------------------------------------- | --------------------------------------------- |
|
||||||
|
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||||
|
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||||
|
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||||
|
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||||
|
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||||
|
| 6 | `serie-conferme` | normale | ✅ (limite noto sotto) | ✅ (`serie-conferme-badge`) |
|
||||||
|
| 7 | `s-tiebreak` | segreto | ✅ (bug corretto, vedi sotto) | ✅ (`s-tiebreak-badge`) |
|
||||||
|
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||||
|
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||||
|
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||||
|
| 11 | `s-cacche` | segreto | ✅ (descrizione corretta, vedi sotto) | ✅ (`s-cacche-badge`) |
|
||||||
|
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||||
|
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||||
|
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||||
|
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||||
|
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problemi noti da sistemare
|
||||||
|
|
||||||
|
- **`badgeSbloccati()` morta** (`badges.ts:283-285`): duplica esattamente
|
||||||
|
`collezioneBadge(g).sbloccati`. Zero riferimenti fuori dalla propria definizione, né in
|
||||||
|
`src/` né nei test. Da rimuovere o documentare perché esiste (es. uso futuro/esterno).
|
||||||
|
- **`categoria` senza vincolo DB** in `badge_social_voti`: la colonna è `text NOT NULL` senza
|
||||||
|
CHECK o FK verso i 5 id di `categorieSocial`
|
||||||
|
(`supabase/migrations/20260803140647_affa1c11-fa92-450f-9f00-02d87195a6d9.sql:4`). I test
|
||||||
|
stessi lo dimostrano scrivendo categorie inesistenti (`"sorriso"`/`"urlo"`,
|
||||||
|
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
||||||
|
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
||||||
|
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
||||||
|
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
|
||||||
|
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
|
||||||
|
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
|
||||||
|
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
|
||||||
|
test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Limiti noti
|
## Limiti noti
|
||||||
|
|
||||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||||
@@ -64,12 +425,39 @@ badge assegnati per voto dai compagni.
|
|||||||
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
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.
|
||||||
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
|
|
||||||
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
|
|
||||||
applicativo.
|
|
||||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||||
browser o dispositivo.
|
browser o dispositivo.
|
||||||
|
|
||||||
|
**Risolto (analisi del badge Sherpa dei palloni)**: prima `g.palloni` (`rosa.ts`) includeva
|
||||||
|
anche i turni che `completaTurni()` propone in automatico per un evento passato senza
|
||||||
|
assegnazione esplicita, non solo quelli confermati in `turni_palloni` — un giocatore poteva
|
||||||
|
vedere avanzare il badge senza aver mai confermato nulla, semplicemente perché l'algoritmo di
|
||||||
|
rotazione l'aveva proposto. Ora `rosa.ts` passa a `conteggioTurni()` solo `turniSalvati` (i
|
||||||
|
turni confermati), non l'output di `completaTurni()`: quest'ultimo resta in uso solo per la
|
||||||
|
UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteggio del badge.
|
||||||
|
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
||||||
|
conseguenza.
|
||||||
|
|
||||||
|
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||||
|
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
|
||||||
|
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||||
|
`g.votiPagella >= VOTI_MINIMI_PAGELLA`, dimostrato con dati reali in `s-tiebreak-badge.test.ts`.
|
||||||
|
Il badge `s-cacche` prometteva invece "partite di **campionato**" nella descrizione senza che
|
||||||
|
nessuna funzione della pipeline lo verificasse mai (`statisticheCacche()` conta qualunque
|
||||||
|
partita) — qui si è scelto di correggere la descrizione, non il codice: il comportamento
|
||||||
|
"qualunque partita conta" resta quello voluto, pinnato in `s-cacche-badge.test.ts`.
|
||||||
|
|
||||||
|
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
|
||||||
|
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
|
||||||
|
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
|
||||||
|
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
|
||||||
|
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
|
||||||
|
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
|
||||||
|
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
|
||||||
|
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
|
||||||
|
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
@@ -77,3 +465,7 @@ badge assegnati per voto dai compagni.
|
|||||||
- 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 i tre badge legati alle serie si sblocchino davvero,
|
||||||
ora che le serie sono calcolate.
|
ora che le serie sono calcolate.
|
||||||
|
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||||
|
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||||
|
- Se un domani serve restringere `conteggioTurni()` per tipo evento (vedi "Problemi noti"),
|
||||||
|
aggiornare anche il test che oggi ne pinna il comportamento permissivo.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Modulo — Calendario ed Eventi
|
||||||
|
|
||||||
|
**Stato:** implementato
|
||||||
|
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
|
||||||
|
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
|
||||||
|
`src/components/crapp/EventoCard.tsx` (card condivisa)
|
||||||
|
**Test:** `test/unit/eventi.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Un unico calendario condiviso per allenamenti, partite, amichevoli ed eventi extra
|
||||||
|
(riunioni, cene di squadra...), al posto di messaggi sparsi in chat. Ogni evento in
|
||||||
|
`eventi_app` diventa il punto a cui si agganciano presenze, convocazioni, MVP, pagelle,
|
||||||
|
scout e turno palloni — la maggior parte degli altri moduli dipende da un `evento.id`.
|
||||||
|
|
||||||
|
## Due schermate, due pubblici
|
||||||
|
|
||||||
|
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
|
||||||
|
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
|
||||||
|
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
|
||||||
|
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.
|
||||||
|
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
|
||||||
|
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||||
|
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||||
|
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||||
|
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database).
|
||||||
|
|
||||||
|
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||||
|
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||||
|
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
||||||
|
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
||||||
|
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
|
||||||
|
eventi virtuali (compleanni).
|
||||||
|
|
||||||
|
## Lettura lato server
|
||||||
|
|
||||||
|
`src/lib/eventi.server.ts` (`leggiEventi()`) è la stessa conversione riga→modello di
|
||||||
|
`eventi.ts`, ma con `supabaseAdmin` per le route API che girano senza sessione utente (es.
|
||||||
|
`sollecita-presenze.ts`, `promemoria-palloni.ts` — vedi `presenze.md` e `palloni.md`) e per
|
||||||
|
`notifiche-smart.ts`, che decide i promemoria da mandare in base agli eventi del giorno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limiti noti
|
||||||
|
|
||||||
|
1. **Cancellare un evento è distruttivo per tutto ciò che vi era agganciato.** Un trigger
|
||||||
|
(`m14_pulizia_dati_evento_cancellato`,
|
||||||
|
[DD-029](../DESIGN_DECISIONS.md#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati))
|
||||||
|
pulisce a cascata presenze, cacche, voti MVP/pagelle/badge social, turni palloni e scout
|
||||||
|
di quell'evento: non è recuperabile con un annulla, e prima di M14 quelle righe restavano
|
||||||
|
orfane nel database (bonificate una tantum da M15/M16, vedi `PROJECT_STATE.md`).
|
||||||
|
2. **Nessuna creazione automatica degli eventi partita dal calendario CSI.** Le gare
|
||||||
|
ufficiali arrivano già come dati (`getEventsByTeamId.php`, vedi `collegamento-csi.md`),
|
||||||
|
ma un amministratore deve comunque creare a mano l'evento corrispondente in `/eventi`
|
||||||
|
perché esistano convocazioni, presenze, MVP e pagelle per quella partita — altrimenti la
|
||||||
|
gara resta visibile solo nello storico CSI, con un dettaglio "solo CSI" più povero
|
||||||
|
(`/partita-csi/$id` invece di `/partita/$id`). In `docs/ROADMAP.md` sotto "Prossimo".
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
# Modulo — Collegamento CSI
|
# Modulo — Collegamento CSI
|
||||||
|
|
||||||
**Stato:** implementato (stagione 2025/26)
|
**Stato:** implementato (stagione 2025/26)
|
||||||
**Route interessata:** `/classifica`
|
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
|
||||||
|
(dettaglio di una gara: formazioni e scontri diretti)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,26 +22,125 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi
|
|||||||
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
|
che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza
|
||||||
API key, ma **non offrono alcuna garanzia di stabilità**.
|
API key, ma **non offrono alcuna garanzia di stabilità**.
|
||||||
|
|
||||||
| Endpoint | Formato | Uso |
|
Le pagine "umane" (`league_details.php`, `team_details.php`) sono gusci lato server: non
|
||||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ |
|
contengono dati, li caricano dopo via JS dagli stessi endpoint `components/*.php` —
|
||||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi |
|
verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati in fondo
|
||||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali |
|
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
|
||||||
|
un `project_id`), nessun codice le chiama direttamente.
|
||||||
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
|
||||||
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
|
||||||
`project-last_results.php`, `team-roster.php`, `team-results.php`.
|
|
||||||
|
|
||||||
### Identificativi (stagione 2025/26)
|
### Identificativi (stagione 2025/26)
|
||||||
|
|
||||||
| Cosa | Valore |
|
| Cosa | Valore |
|
||||||
| ------------------- | -------------------------------------- |
|
| ---------------------- | ---------------------------------------- |
|
||||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||||
| `project_id` | `767` |
|
| `project_id` (girone) | `767` |
|
||||||
|
| Coppa | PVM Coppa CSI Misto Silver |
|
||||||
|
| `project_id` (coppa) | `848` |
|
||||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||||
| `team_id` | `3359` |
|
| `team_id` | `3359` |
|
||||||
| Girone | B |
|
| Girone | B |
|
||||||
|
|
||||||
Gli identificativi sono costanti in `src/lib/csi-core.ts`.
|
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||||
|
`src/lib/csi-core.ts`.
|
||||||
|
|
||||||
|
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||||
|
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||||
|
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||||
|
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||||
|
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
|
||||||
|
`team_details.php` nel browser, questo componente basta.
|
||||||
|
|
||||||
|
### Endpoint usati dall'app
|
||||||
|
|
||||||
|
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||||
|
| -------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------ |
|
||||||
|
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||||
|
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||||
|
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||||
|
|
||||||
|
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
|
||||||
|
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
|
||||||
|
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
|
||||||
|
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
|
||||||
|
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
|
||||||
|
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
|
||||||
|
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
|
||||||
|
condivisa, nessun parser dedicato per la Coppa.
|
||||||
|
|
||||||
|
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
|
||||||
|
Coppa insieme, vedi limite 4), con: `id`, `start` (`"2025-11-12T22:00:00"`, data+ora
|
||||||
|
locale), `team1`/`team2` (nomi squadre), `result` (`"3 - 1"`, stringa libera), `partials`
|
||||||
|
(`"25 - 23</br>23 - 25</br>..."`, HTML nei separatori), `field` (impianto), `project`
|
||||||
|
(nome campionato, es. `"PVM - Coppa CSI Misto Silver"` — usato per distinguere le
|
||||||
|
competizioni, vedi limite 4), `league`, `group` (es. `"Girone B"`), `match_number`. Letto
|
||||||
|
da `partiteDaEventi()` in `csi-core.ts`, che estrae punteggio/parziali con le regex
|
||||||
|
`punteggio()`/`parziali()` — vedi limite 5 sui rischi di questo parsing.
|
||||||
|
|
||||||
|
**Campi leggeri aggiuntivi letti dallo stesso JSON** (nessuna fetch in più, solo campi in
|
||||||
|
più letti dallo stesso `evento`): `team1_logo`/`team2_logo` (URL del logo, quello
|
||||||
|
dell'avversario finisce in `PartitaCsi.logoAvversario`), `group` (girone, es. `"Girone
|
||||||
|
B"`), `match_number` (n° gara, es. `"5/XEB"`), `referees` (arbitro, spesso vuoto), `link`
|
||||||
|
(URL del referto ufficiale, `match_details.php?id=…`).
|
||||||
|
|
||||||
|
Altri endpoint disponibili ma non usati: `getEventsByProjectIdHierarchical.php` (tutte le
|
||||||
|
gare del campionato), `project-chart-rankings.php` (solo punti), `project-next_matches.php`,
|
||||||
|
`project-last_results.php`, `project-sheets-scorers.php`/`-results.php`/`-measures.php`
|
||||||
|
(sotto-tab di `project-sheets`: marcatori, risultati per giornata, provvedimenti
|
||||||
|
disciplinari), `team-roster.php` (rosa), `team-staff.php`, `team-results.php`,
|
||||||
|
`team-scorers.php`.
|
||||||
|
|
||||||
|
### Dettaglio di una singola gara (formazioni e scontri diretti)
|
||||||
|
|
||||||
|
Il referto di ogni gara sul portale (`match_details.php?id=<matchId>`, dove `matchId` è lo
|
||||||
|
stesso `id` restituito da `getEventsByTeamId.php`) carica a sua volta tre componenti via
|
||||||
|
`assets/js/match.js`:
|
||||||
|
|
||||||
|
| Endpoint | Formato | Uso |
|
||||||
|
| ----------------------------------------------- | ------- | --------------------------------------------------------- |
|
||||||
|
| `components/match-main.php?match_id=<id>` | HTML | Giornata e una nota libera sotto l'impianto |
|
||||||
|
| `components/match-players.php?match_id=<id>` | HTML | Formazioni: titolari, panchina, staff di entrambe le squadre |
|
||||||
|
| `components/match-stats.php?match_id=<id>` | HTML | Storico scontri diretti e probabilità di vittoria calcolata dal CSI |
|
||||||
|
|
||||||
|
Altri due componenti della stessa pagina non sono usati: `match-live.php` (diretta testuale
|
||||||
|
punto-per-punto, utile solo a gara in corso) e la lista dettagliata dei precedenti dentro
|
||||||
|
`historyModal` in `match-stats.php` (un elenco partita-per-partita meno affidabile del
|
||||||
|
riepilogo aggregato — vedi sotto).
|
||||||
|
|
||||||
|
**`match-main.php`** — `parseInfoPartita()` (`csi-core.ts`) legge: la giornata (es. `"2ª
|
||||||
|
Giornata"`, assente per gare fuori dal girone come la finale di Coppa) e una nota libera
|
||||||
|
sotto l'impianto (`nota`). Quella nota **non ha un formato fisso**: a volte è `"Pubblico
|
||||||
|
non ammesso"`, a volte il nome della palestra, a volte altro — va mostrata così com'è, non
|
||||||
|
interpretata come un flag booleano.
|
||||||
|
|
||||||
|
**`match-players.php`** — `parseFormazioni()` legge due blocchi `<div class="col-12
|
||||||
|
col-md-6 mt-4">`, separati nel markup dal commento `<!-- SQUADRA OSPITE -->`, ciascuno con
|
||||||
|
una `<ul class="list-group">` di giocatori in ordine titolari → divisore "A DISPOSIZIONE"
|
||||||
|
→ panchina → divisore "STAFF" → staff (numero maglia, nome, ruolo; lo staff ha la stessa
|
||||||
|
struttura ma senza numero). Quale dei due blocchi sia "noi" si riconosce con
|
||||||
|
`isNostraSquadra()`, non assumendo un ordine fisso casa/ospite — verificato che l'ordine
|
||||||
|
nel markup è sempre "squadra casa" prima e "squadra ospite" dopo, ma il codice non si fida
|
||||||
|
di questo per evitare sorprese. `null` se nessuno dei due nomi è la nostra squadra
|
||||||
|
(referto non ancora compilato o formato cambiato).
|
||||||
|
|
||||||
|
**`match-stats.php`** — `parsePrecedenti()` legge il blocco di riepilogo in fondo alla
|
||||||
|
pagina (non la lista `historyModal` partita-per-partita, che in un caso osservato conteneva
|
||||||
|
gare di **altre squadre** senza relazione con la gara corrente — dato non affidabile da
|
||||||
|
interpretare): numero di precedenti, vittorie totali/in casa/fuori di entrambe, probabilità
|
||||||
|
di vittoria calcolata dal CSI. **Attenzione a un'insidia verificata sui dati reali**: le
|
||||||
|
due barre di probabilità sono colorate per chi è favorito (verde = più alta, rosso = più
|
||||||
|
bassa), **non** per casa/ospite — un parser ingenuo che associasse il verde alla squadra
|
||||||
|
casa sbaglierebbe metà delle volte. Il parser usa invece l'ordine di apparizione nel
|
||||||
|
markup (prima barra = squadra casa, seconda = ospite), coerente con l'ordine dei blocchi
|
||||||
|
"Squadra casa"/"Squadra ospite" più sopra nella stessa pagina. Con "0 precedenti" il CSI
|
||||||
|
omette del tutto le righe vittorie/in-casa/fuori (restano a `0`) ma la probabilità resta
|
||||||
|
comunque presente: `parsePrecedenti()` distingue quindi "0 precedenti" (oggetto valido con
|
||||||
|
`totale: 0`) da "formato non riconosciuto" (`null`, solo se nessuno dei due nomi squadra è
|
||||||
|
identificabile).
|
||||||
|
|
||||||
|
**Formato di `DettaglioPartitaCsi`** (il JSON che compongono insieme):
|
||||||
|
`{ giornata, nota, formazioni: { noi, avversario } | null, precedenti: PrecedentiCsi | null
|
||||||
|
}`, dove ogni `FormazioneSquadra` è `{ squadra, titolari: GiocatoreFormazione[], panchina,
|
||||||
|
staff: StaffFormazione[] }` e `GiocatoreFormazione` è `{ numero, nome, ruolo }`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,28 +150,81 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`.
|
|||||||
CSI (portale)
|
CSI (portale)
|
||||||
↓ fetch server-side, cache 6 ore
|
↓ fetch server-side, cache 6 ore
|
||||||
/api/public/csi → src/routes/api/public/csi.ts
|
/api/public/csi → src/routes/api/public/csi.ts
|
||||||
↓ JSON { classifica, partite, girone, aggiornato }
|
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
|
||||||
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
||||||
↓
|
↓
|
||||||
/classifica → src/routes/classifica.tsx
|
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone
|
||||||
|
sotto; tab "Storico partite": ogni gara cliccabile)
|
||||||
|
|
||||||
|
CSI (portale, 3 endpoint)
|
||||||
|
↓ fetch server-side on-demand, cache per-partita 6 ore
|
||||||
|
/api/public/csi-partita/$id → src/routes/api/public/csi-partita.$id.ts
|
||||||
|
↓ JSON DettaglioPartitaCsi { giornata, nota, formazioni, precedenti }
|
||||||
|
useCsiPartita() → src/lib/csi-partita.ts (React Query, staleTime 6h)
|
||||||
|
↓
|
||||||
|
DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazioni + scontri diretti)
|
||||||
|
↓
|
||||||
|
/partita/$id (evento CrAPP collegato) o /partita-csi/$id (nessun evento collegato)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe),
|
- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe,
|
||||||
`partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`.
|
usata sia per `classifica` sia per `classificaCoppa`), `partiteDaEventi()` (JSON → partite),
|
||||||
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di
|
`isNostraSquadra()`, `partiteGiocate()`, `matchDaPartitaCsi()` (porta i campi leggeri —
|
||||||
6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno).
|
logo, girone, n° gara, arbitro, link — nella forma usata dalle liste), `parseInfoPartita()`,
|
||||||
- **`src/lib/csi.ts`** — hook client, una lettura per sessione.
|
`parseFormazioni()`, `parsePrecedenti()` (vedi sezione precedente).
|
||||||
|
- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI per classifica e
|
||||||
|
partite: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in
|
||||||
|
memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne
|
||||||
|
esiste uno). Se solo la Coppa fallisce (`scarica(...).catch(() => "")`) la risposta resta
|
||||||
|
comunque `200` con `classificaCoppa: []`: è un dato supplementare, non blocca la classifica
|
||||||
|
del girone.
|
||||||
|
- **`src/routes/api/public/csi-partita.$id.ts`** — route separata per il dettaglio di una
|
||||||
|
singola gara: tre fetch in parallelo (`match-main`/`match-players`/`match-stats.php`). Cache
|
||||||
|
in memoria **per `matchId`** (una `Map`, non un singolo valore come `csi.ts`), stessa
|
||||||
|
finestra di 6 ore. A differenza di `/api/public/csi`, qui un fallimento del fetch è fatale
|
||||||
|
(`503`, nessun fallback "meglio un dato vecchio"): non c'è ancora una cache da riusare la
|
||||||
|
prima volta che qualcuno apre una gara, e un errore upstream reale (verificato: CSI risponde
|
||||||
|
`500` su `match-stats.php` per un `match_id` inventato) va distinto da "gara senza
|
||||||
|
formazioni ancora pubblicate" (quell'endpoint risponde `200` con markup vuoto, gestito da
|
||||||
|
`parseFormazioni()`/`parsePrecedenti()` restituendo `null`, non da un errore HTTP).
|
||||||
|
- **`src/lib/csi.ts`** / **`src/lib/csi-partita.ts`** — hook client React Query, stessa
|
||||||
|
`staleTime` di 6h. `useCsiPartita(matchId)` è `enabled` solo quando `matchId` è definito:
|
||||||
|
va montato solo nel dettaglio di una gara, mai in una lista (altrimenti sarebbe una fetch
|
||||||
|
per riga, vedi "Regole rispettate" sotto).
|
||||||
|
- **`src/components/crapp/DettaglioCsi.tsx`** — UI condivisa tra `/partita/$id` e
|
||||||
|
`/partita-csi/$id`: `LogoSquadra` (logo con hotlink diretto al portale CSI, si nasconde da
|
||||||
|
sola se l'immagine non carica invece di mostrare un'icona rotta), `MetaPartitaCsi` (girone,
|
||||||
|
n° gara, arbitro, link al referto — campi leggeri, zero fetch aggiuntive), `DettaglioCsiEsteso`
|
||||||
|
(formazioni + scontri diretti, monta `useCsiPartita()`).
|
||||||
|
- **`src/routes/partita-csi.$id.tsx`** — dettaglio "solo CSI" per le gare **senza** un evento
|
||||||
|
CrAPP collegato (l'app non crea ancora eventi automaticamente dal calendario CSI, vedi
|
||||||
|
"Evoluzioni possibili"): nessuna convocazione/presenza/MVP/scout, solo risultato, parziali
|
||||||
|
e i dati CSI di questa sezione. `id` è l'`id` della gara sul portale CSI
|
||||||
|
(`PartitaCsi.id`), non un evento CrAPP.
|
||||||
|
- **`src/routes/partita.$id.tsx`** — per le gare **con** un evento CrAPP collegato, mostra le
|
||||||
|
stesse informazioni CSI (logo, metadati, formazioni, scontri diretti) in più rispetto a
|
||||||
|
prima, quando `csiMatch` esiste per quella data.
|
||||||
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
|
- **`test/unit/csi-core.test.ts`** — check del parsing: `bun test/unit/csi-core.test.ts`.
|
||||||
Con `CSI_LIVE=1` verifica anche gli endpoint reali.
|
Con `CSI_LIVE=1` verifica anche gli endpoint reali, incluse formazioni e precedenti di una
|
||||||
|
gara giocata.
|
||||||
|
|
||||||
### Regole rispettate
|
### Regole rispettate
|
||||||
|
|
||||||
- **Nessuna chiamata dal browser**: il portale viene contattato solo lato server, al massimo
|
- **Nessuna chiamata dal browser per classifica/partite**: il portale viene contattato solo
|
||||||
4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
|
lato server, al massimo 4 volte al giorno per `/api/public/csi`, indipendentemente da
|
||||||
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della tabella.
|
quanti giocatori aprono l'app (regola anti-consumo). **`/api/public/csi-partita/$id` è
|
||||||
|
diverso di proposito**: è on-demand, chiamato solo quando un giocatore apre il dettaglio
|
||||||
|
di una gara specifica (mai precaricato in una lista, vedi `useCsiPartita()` sopra) — non
|
||||||
|
rientra nel limite delle 4 chiamate/giorno perché non è un dato mostrato a tutti a ogni
|
||||||
|
apertura dell'app, ma cache comunque 6 ore per evitare rifetch ripetuti sulla stessa gara.
|
||||||
|
- **Nessuna dipendenza nuova**: parsing con espressioni regolari sulla struttura della
|
||||||
|
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
|
||||||
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
||||||
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
||||||
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
||||||
|
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
|
||||||
|
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
|
||||||
|
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
|
||||||
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
|
- **Portabilità (DD-013)**: endpoint HTTP standard, nessun servizio esclusivo.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,18 +236,100 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
|
|||||||
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
||||||
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da
|
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||||
`team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato
|
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||||
a mano in `csi-core.ts`.
|
|
||||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||||
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
|
istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di
|
||||||
Supabase riempita da un job cron (stesso pattern di `promemoria-palloni`).
|
`/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i
|
||||||
4. **I risultati includono anche la Coppa**, non solo il girone di campionato.
|
dati in una tabella Supabase riempita da un job cron (stesso pattern di
|
||||||
|
`promemoria-palloni`).
|
||||||
|
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
|
||||||
|
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
|
||||||
|
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
|
||||||
|
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
|
||||||
|
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
|
||||||
|
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||||
|
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
|
||||||
|
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
|
||||||
|
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||||
|
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
|
||||||
|
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
|
||||||
|
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
|
||||||
|
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
|
||||||
|
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
|
||||||
|
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
|
||||||
|
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
|
||||||
|
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
|
||||||
|
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
|
||||||
|
finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio
|
||||||
|
girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id`
|
||||||
|
figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo.
|
||||||
|
5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e
|
||||||
|
`partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con
|
||||||
|
un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI
|
||||||
|
cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la regex
|
||||||
|
non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata"
|
||||||
|
(`setNostri`/`setLoro` a `null`) — silenziosamente, senza errori. Se invece la risposta
|
||||||
|
cambiasse forma radicalmente (non più un array), `partiteDaEventi()` torna `[]`.
|
||||||
|
**Conseguenza sugli obiettivi di squadra**: le "vittorie in campionato" (`obiettivi.ts`,
|
||||||
|
obiettivi o3/o4/o5) dipendono da `partiteGiocate(csi.partite)` — se il parsing delle partite
|
||||||
|
si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali.
|
||||||
|
**Il fallback della route non se ne accorgerebbe da solo**: `/api/public/csi` lancia un
|
||||||
|
errore solo se *sia* la classifica *sia* le partite sono vuote insieme
|
||||||
|
(`classifica.length === 0 && partite.length === 0`); se si rompe solo il parsing delle
|
||||||
|
partite mentre la classifica HTML continua a funzionare, la route risponde comunque `200`
|
||||||
|
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
|
||||||
|
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
|
||||||
|
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error` —
|
||||||
|
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
|
||||||
|
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
|
||||||
|
anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||||
|
(`src/routes/classifica.tsx`), dove mostra un badge discreto ("Il portale CSI potrebbe aver
|
||||||
|
cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati
|
||||||
|
alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la
|
||||||
|
pagina campionato molto meno. Il fix, quando succede, è isolato a
|
||||||
|
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
|
||||||
|
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
|
||||||
|
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
|
||||||
|
fixture nel nuovo formato.
|
||||||
|
6. **Il portale può essere del tutto irraggiungibile, non solo cambiare formato.** Scenario
|
||||||
|
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
|
||||||
|
nemmeno JSON): l'8 settembre 2026 `getEventsByTeamId.php` ha risposto con `200` ma un
|
||||||
|
errore SQL del loro backend in chiaro al posto del JSON
|
||||||
|
(`Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist`,
|
||||||
|
verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/
|
||||||
|
csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde
|
||||||
|
`503 "CSI non raggiungibile"` (o serve la cache se ce n'è una) — nessun crash, ma nessun
|
||||||
|
dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di
|
||||||
|
`test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una
|
||||||
|
volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece
|
||||||
|
di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità
|
||||||
|
temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo
|
||||||
|
permanente: tornano a girare da soli non appena il CSI risponde di nuovo con `200`.
|
||||||
|
7. **I loghi delle squadre sono "hotlinked" direttamente dal browser al portale CSI**
|
||||||
|
(`LogoSquadra` in `DettaglioCsi.tsx` punta a `logoAvversario`, un URL
|
||||||
|
`livescore.csibologna.it/images/...`). È un'eccezione consapevole alla regola "nessuna
|
||||||
|
chiamata dal browser al CSI": un'immagine, a differenza dei dati, non ha bisogno di
|
||||||
|
passare dalla cache server per restare aggiornata, e proxarla/cacherla lato server per
|
||||||
|
ogni squadra avversaria (potenzialmente decine a stagione) sarebbe uno sforzo sproporzionato
|
||||||
|
al beneficio. Se un logo non carica (URL cambiato, squadra senza foto),
|
||||||
|
`LogoSquadra` si nasconde da sola (`onError` → `null`) invece di mostrare un'icona rotta.
|
||||||
|
8. **L'ordine "titolari"/"A disposizione" in `parseFormazioni()` è quello del referto CSI,
|
||||||
|
non necessariamente il sestetto che è sceso davvero in campo al fischio d'inizio.** Il
|
||||||
|
CSI non separa esplicitamente "chi ha giocato titolare" da "chi era comunque convocato e
|
||||||
|
in lista gara": il divisore "A DISPOSIZIONE" nella pagina sembra riflettere l'ordine di
|
||||||
|
inserimento nel referto più che le sostituzioni reali. Va quindi presentato come "referto
|
||||||
|
del CSI", non come cronaca esatta di chi ha giocato quanto.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
|
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
|
||||||
- Creazione automatica degli eventi partita da calendario CSI.
|
- Creazione automatica degli eventi partita da calendario CSI — risolverebbe anche il
|
||||||
|
limite 4 di sopra: ogni gara avrebbe un evento CrAPP e andrebbe sempre su `/partita/$id`,
|
||||||
|
senza più bisogno di `/partita-csi/$id` per le gare "orfane".
|
||||||
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
|
- Confronto tra i parziali ufficiali e quelli dello Scout Live.
|
||||||
|
- Tabellone a eliminazione della fase finale di Coppa (limite 4): oggi non tracciato, il
|
||||||
|
`project_id` figlio (es. `905`) andrebbe scoperto a runtime leggendo il link dentro
|
||||||
|
`project-sheets.php?project_id=848` invece di essere una costante.
|
||||||
|
|||||||
+15
-5
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Votazione MVP
|
# Modulo — Votazione MVP
|
||||||
|
|
||||||
**Stato:** implementato (v1.0)
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -37,6 +37,9 @@ restano nel database ma non vengono più letti da nessuna schermata).
|
|||||||
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||||
|
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
||||||
|
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||||
|
incoronare nessuno, nemmeno senza concorrenza.
|
||||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||||
nel profilo e in home.
|
nel profilo e in home.
|
||||||
@@ -48,10 +51,17 @@ restano nel database ma non vengono più letti da nessuna schermata).
|
|||||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||||
vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero
|
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
|
||||||
presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo
|
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
|
||||||
applicativi — chi scrive su PostgREST li aggira.
|
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||||
- In caso di parità, nessun MVP viene assegnato per quella partita.
|
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||||
|
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||||
|
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||||
|
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||||
|
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||||
|
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||||
|
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||||
|
partita.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Modulo — Obiettivi di squadra
|
# Modulo — Obiettivi di squadra
|
||||||
|
|
||||||
**Stato:** implementato (v1.0), con costanti stagionali da aggiornare a mano
|
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||||
|
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -15,47 +16,159 @@ comportamenti di squadra oltre alla singola prestazione.
|
|||||||
|
|
||||||
## Dati
|
## Dati
|
||||||
|
|
||||||
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts` che legge dati
|
Nessuna tabella dedicata: ogni obiettivo è una funzione pura in `obiettivi.ts`
|
||||||
già aggregati altrove (`risposte_presenze`, `pagelle_voti`, i risultati ufficiali CSI, le
|
(`obiettiviSquadra()`) che legge dati già aggregati altrove (`risposte_presenze`,
|
||||||
serie).
|
`pagelle_voti`, i risultati ufficiali CSI, le serie di presenza). `obiettiviOrdinati()` li
|
||||||
|
ordina mettendo i completati in coda e gli altri per progresso decrescente.
|
||||||
|
|
||||||
|
Non c'è nessuno stato da tenere sincronizzato quando un evento viene cancellato: gli obiettivi
|
||||||
|
sono ricalcolati da zero a ogni render partendo dall'elenco eventi corrente, quindi un evento
|
||||||
|
sparito da `eventi_app` smette semplicemente di contare, senza bisogno di nessuna pulizia
|
||||||
|
esplicita. Il problema che *sembrava* riguardare gli obiettivi era in realtà nelle tabelle
|
||||||
|
collegate a un evento (presenze, pagelle, MVP, ecc.), che restavano orfane a database dopo la
|
||||||
|
cancellazione: risolto a livello database con un trigger (migration
|
||||||
|
`m14_pulizia_dati_evento_cancellato`, DD-029), non nel modulo Obiettivi.
|
||||||
|
|
||||||
|
`obiettiviSquadra(rosa, ctx, oggi)` accetta un terzo parametro opzionale `oggi: Date` (default
|
||||||
|
`new Date()`) per iniettare una data deterministica nei test — usato dai due obiettivi con mese
|
||||||
|
corrente dinamico (vedi sotto).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Obiettivi definiti
|
## Obiettivi definiti
|
||||||
|
|
||||||
| Obiettivo | Calcolo | Target | Fonte |
|
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||||
| ---------------------------------- | ----------------------------------------------- | ------ | ---------------------------------------------- |
|
| ----- | ----------------------------------- | ----------------------------------------------------- | ----------------------- | ------------------------------------------ |
|
||||||
| 90% presenze ad agosto | risposte presente/ritardo sugli eventi del mese | 90% | `risposte_presenze` |
|
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||||
| Tutti rispondono alle convocazioni | risposte totali / eventi possibili | 90% | `risposte_presenze` |
|
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||||
| 250 presenze complessive | somma presenze di tutta la rosa | 250 | aggregato da `useRosa()` |
|
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||||
| Media pagelle da 7.5 | media di squadra | 7.5 | `pagelle_voti` |
|
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||||
| 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||||
| Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 | `serieAllenamenti` |
|
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||||
| 1 / 5 / 10 vittorie in campionato | partite vinte da dati CSI ufficiali | 1/5/10 | modulo [Collegamento CSI](collegamento-csi.md) |
|
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||||
| 1 evento di squadra al mese | eventi di tipo "evento" nel mese | 1 | `eventi_app` |
|
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||||
|
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||||
|
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||||
|
|
||||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||||
smart (`notifiche-smart.ts`).
|
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||||
|
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||||
|
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||||
|
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Limiti noti
|
## Obiettivi mensili — mese dinamico
|
||||||
|
|
||||||
- "Continuità di squadra" dipende da `serieAllenamenti` (vedi
|
`o1` ("90% presenze del mese") e `o6` ("1 evento di squadra al mese") si azzerano
|
||||||
[Serie di presenze](serie-presenze.md)), calcolato sui dati reali: un evento passato senza
|
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||||
risposta vale come assenza e azzera la serie, quindi l'obiettivo misura anche quanto la
|
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||||
squadra risponde alle convocazioni, non solo la presenza.
|
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||||
- **Il mese di riferimento è una costante fissa nel codice** (agosto 2026): gli obiettivi
|
seguono di conseguenza.
|
||||||
legati al mese corrente vanno aggiornati manualmente a ogni cambio di mese o stagione, oggi
|
|
||||||
sono "congelati" su un mese già passato.
|
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||||
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
|
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||||
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
|
anch'essa l'ultimo giorno del mese corrente (`fineMese(oggi)`), non più una data fissa.
|
||||||
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
|
|
||||||
ogni stagione.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Continuità di squadra — il target 12 è il minimo per un 6vs6
|
||||||
|
|
||||||
- Calcolare il mese di riferimento dinamicamente invece di una costante hardcoded.
|
Il target di 12 giocatori con almeno 3 allenamenti consecutivi (`o11`) **non è arbitrario**: è
|
||||||
|
il numero minimo di giocatori per schierare due sestetti (6 contro 6) in allenamento. A
|
||||||
|
differenza degli altri target fissi, non va scalato in proporzione alla rosa se questa cambia
|
||||||
|
dimensione — resta 12 finché l'obiettivo è "riuscire ad allenarsi in modo completo".
|
||||||
|
|
||||||
|
Dipende da `serieAllenamenti` (vedi [Serie di presenze](serie-presenze.md)), calcolato sui dati
|
||||||
|
reali: un evento passato senza risposta vale come assenza e azzera la serie, quindi l'obiettivo
|
||||||
|
misura anche quanto la squadra risponde alle convocazioni, non solo la presenza fisica.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vittorie in campionato (o3/o4/o5) — dipendenza dal portale CSI
|
||||||
|
|
||||||
|
Le vittorie (`ctx.vittorie`) arrivano dal **JSON** delle partite del portale CSI Bologna
|
||||||
|
(`getEventsByTeamId.php`, non la pagina HTML della classifica), tramite
|
||||||
|
`partiteGiocate(csi.partite).filter(p => p.setNostri > p.setLoro)` calcolato in
|
||||||
|
`src/lib/rosa.ts` (`useObiettivi()`). `o3`/`o4`/`o5` sono lo stesso numero di vittorie letto a
|
||||||
|
tre soglie diverse (1/5/10), ciascuna cappata con `Math.min` — nessuna delle tre supera mai il
|
||||||
|
proprio target, nemmeno con più vittorie di quante ne servano.
|
||||||
|
|
||||||
|
### Il limite: il parsing del JSON può rompersi in silenzio
|
||||||
|
|
||||||
|
`result` e `partials` nella risposta di `getEventsByTeamId.php` sono stringhe libere tipo
|
||||||
|
`"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il
|
||||||
|
portale CSI cambiasse formato (es. `"3:1"`, o un punteggio come oggetto invece che stringa), la
|
||||||
|
regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" — **senza
|
||||||
|
errori**. Se la risposta cambiasse forma radicalmente (non più un array), `partiteDaEventi()`
|
||||||
|
torna `[]`. In entrambi i casi `o3`/`o4`/`o5` restano bloccati a 0% anche a fronte di vittorie
|
||||||
|
reali, e il fallback della route (`/api/public/csi`) non se ne accorgerebbe da solo: lancia un
|
||||||
|
errore solo se *sia* la classifica *sia* le partite sono vuote insieme, quindi se si rompe solo
|
||||||
|
il JSON delle partite mentre la classifica HTML continua a funzionare, la route risponde
|
||||||
|
comunque `200` con `partite: []`.
|
||||||
|
|
||||||
|
### Come è mitigato oggi
|
||||||
|
|
||||||
|
- **`partiteFormatoSospetto()`** (`csi-core.ts`) confronta gli eventi grezzi ricevuti con il
|
||||||
|
risultato di `partiteDaEventi()`: se ci sono eventi ma nessuno è stato riconosciuto come
|
||||||
|
nostra partita, il formato è quasi certamente cambiato (distingue così un vero "formato
|
||||||
|
rotto" da un legittimo "nessuna gara ancora in programma", dove gli eventi grezzi sono vuoti
|
||||||
|
anche loro).
|
||||||
|
- La route (`src/routes/api/public/csi.ts`) logga un `console.error` quando succede.
|
||||||
|
- Il flag viaggia anche nella risposta JSON (`DatiCsi.formatoSospetto`) fino a `/classifica`
|
||||||
|
(`src/routes/classifica.tsx`), dove sostituisce la riga "Dati CSI aggiornati alle..." con un
|
||||||
|
badge discreto color warning ("Il portale CSI potrebbe aver cambiato formato: dati da
|
||||||
|
verificare.") — visibile a chi apre la pagina campionato, non solo nei log del server.
|
||||||
|
|
||||||
|
### Come fixarlo, se succede
|
||||||
|
|
||||||
|
1. **Vedere il nuovo formato**: guardare la risposta reale dell'endpoint, o lanciare
|
||||||
|
`CSI_LIVE=1 bun test/unit/csi-core.test.ts` (interroga il portale vero).
|
||||||
|
2. **Aggiornare il parsing** in `src/lib/csi-core.ts`: quasi sempre basta toccare
|
||||||
|
`punteggio()`/`parziali()` (le regex sul formato del punteggio) o i nomi dei campi letti in
|
||||||
|
`partiteDaEventi()`. Il resto dell'app consuma solo i tipi già puliti che questo file
|
||||||
|
produce (`DatiCsi`, `PartitaCsi[]`), quindi il fix resta isolato.
|
||||||
|
3. Serve toccare anche `src/routes/api/public/csi.ts` solo se cambiano gli **URL/endpoint**
|
||||||
|
stessi o serve autenticazione — non per un semplice cambio di formato dei dati.
|
||||||
|
4. **Aggiornare i test**: `test/unit/csi-core.test.ts` con fixture nel nuovo formato, altrimenti
|
||||||
|
restano verdi contro un formato che non esiste più.
|
||||||
|
|
||||||
|
Dettagli completi (endpoint, identificativi di stagione, altri limiti del collegamento CSI) in
|
||||||
|
[Collegamento CSI](collegamento-csi.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copertura test
|
||||||
|
|
||||||
|
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||||
|
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||||
|
|
||||||
|
| Obiettivi | Unit test | Integration test |
|
||||||
|
| ------------ | -------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||||
|
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||||
|
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||||
|
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||||
|
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||||
|
|
||||||
|
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||||
|
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||||
|
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||||
|
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||||
|
(evento dentro/fuori mese), scadenza dinamica.
|
||||||
|
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||||
|
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||||
|
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||||
|
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||||
|
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||||
|
della tabella (`pagelle_no_autovoto`, `pagelle_voto_range`), li rilegge, verifica media
|
||||||
|
arrotondata e conteggio.
|
||||||
|
- **o3/o4/o5** (CSI reale, non Supabase — le vittorie non toccano il database): estende
|
||||||
|
`test/integration/api.test.ts`, che già chiama `/api/public/csi` dal vivo. Legge le vittorie
|
||||||
|
vere del giorno con la stessa logica di `useObiettivi()`, le passa a `obiettiviSquadra()` e
|
||||||
|
verifica cap e target su dati reali.
|
||||||
|
|
||||||
|
Per rilanciare tutto: `npm run test` (unit, nessuna rete) e `npm run test:integration`
|
||||||
|
(richiede `npx supabase start` per o1/o2/o6/o7/o11/o12/o13, e rete verso CSI Bologna per
|
||||||
|
o3/o4/o5 — quest'ultimo gira comunque anche senza stack Supabase locale).
|
||||||
|
|||||||
+25
-12
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Pagelle
|
# Modulo — Pagelle
|
||||||
|
|
||||||
**Stato:** implementato (v1.0)
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
**File principali:** `src/lib/pagelle.ts`, `src/components/crapp/Pagelle.tsx`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,9 +29,19 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
|||||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||||
volte, l'ultimo voto sovrascrive il precedente.
|
volte, l'ultimo voto sovrascrive il precedente.
|
||||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||||
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita;
|
**tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
|
||||||
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`.
|
"la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
|
||||||
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore.
|
singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
|
||||||
|
`squadra.tsx`.
|
||||||
|
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
|
||||||
|
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
|
||||||
|
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti, e mostrato
|
||||||
|
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||||
|
- La StatTile **home** applica la stessa soglia del badge Pagellone (DD-028) tramite la
|
||||||
|
funzione pura `mediaVotoColpoDOcchio()`: sotto `VOTI_MINIMI_PAGELLA` voti ricevuti mostra
|
||||||
|
`—` invece della media, non solo quando i voti sono zero. È stata estratta come funzione
|
||||||
|
testabile (coerente con DD-020) invece di restare una condizione inline nella route. Le
|
||||||
|
StatTile di **profilo** e **squadra** non applicano questa soglia (vedi "Limiti noti").
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,25 +49,28 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
|||||||
|
|
||||||
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
|
||||||
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
|
||||||
voto in UI.
|
voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
|
||||||
|
`evento_permette_voto()`, `pagelle_voti`).
|
||||||
|
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
|
||||||
|
database, non solo in UI (stessa RLS di sopra).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Limiti noti
|
## Limiti noti
|
||||||
|
|
||||||
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
|
|
||||||
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
|
|
||||||
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
|
||||||
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
|
||||||
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
|
||||||
- Nessun controllo a livello database che il votante sia realmente un convocato della
|
- La media mostrata nel **profilo** e in **squadra** non richiede un numero minimo di voti:
|
||||||
partita: solo filtro applicativo.
|
con un solo voto ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`)
|
||||||
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media
|
e la StatTile **home** (DD-028) applicano invece la stessa soglia minima prima di
|
||||||
coincide con quel voto.
|
considerarla — profilo e squadra no.
|
||||||
|
- Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
|
||||||
|
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
|
||||||
|
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
|
||||||
- Far rispettare `pagelleChiuse` anche via RLS.
|
|
||||||
|
|||||||
+17
-8
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Palloni
|
# Modulo — Palloni
|
||||||
|
|
||||||
**Stato:** implementato (v1.0)
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
**File principali:** `src/lib/palloni.ts`, `src/lib/palloni-core.ts`,
|
||||||
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
`src/components/crapp/TurnoPalloni.tsx`, `src/components/crapp/PromemoriaPalloni.tsx`,
|
||||||
`src/routes/api/public/promemoria-palloni.ts`
|
`src/routes/api/public/promemoria-palloni.ts`
|
||||||
@@ -33,11 +33,17 @@ compaiono.
|
|||||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||||
upsert su `evento_id`.
|
upsert su `evento_id`.
|
||||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||||
ricalcolato a runtime da `conteggioTurni()` su turni salvati **più proposte non ancora
|
ricalcolato a runtime da `conteggioTurni()` sui **soli turni confermati** (`turniSalvati`
|
||||||
confermate** (partite/eventi) — non è uno storico in tabella dedicata. Conta solo gli
|
in `rosa.ts`) — non è uno storico in tabella dedicata, ma non include le proposte
|
||||||
eventi già passati (`e.data < oggi`, stesso criterio delle presenze): un turno assegnato
|
automatiche di `completaTurni()` (quelle restano solo per la UI di rotazione,
|
||||||
in anticipo per un allenamento futuro non è ancora "portato", quindi non sale finché quel
|
`TurnoPalloni.tsx`/`PromemoriaPalloni.tsx`). Conta solo gli eventi già passati (`e.data <
|
||||||
giorno non arriva.
|
oggi`, stesso criterio delle presenze): un turno assegnato in anticipo per un allenamento
|
||||||
|
futuro non è ancora "portato", quindi non sale finché quel giorno non arriva.
|
||||||
|
- `serieConsecutivaPalloni()` (`palloni-core.ts`) calcola le volte **consecutive** in cui il
|
||||||
|
giocatore ha portato i palloni (`Giocatore.seriePalloni` in `rosa.ts`), mostrate nel
|
||||||
|
sottotitolo della classifica interna di Squadra quando si ordina per Palloni. Stesso
|
||||||
|
criterio "solo eventi già passati" di `conteggioTurni()`; un evento passato senza turno
|
||||||
|
confermato non spezza la serie di nessuno (viene saltato, non conta come "non portati").
|
||||||
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
- `TurnoPalloni.tsx` mostra/assegna il turno sulla card di un evento; `PromemoriaPalloni.tsx`
|
||||||
è il banner in Home per il giocatore di turno.
|
è il banner in Home per il giocatore di turno.
|
||||||
|
|
||||||
@@ -62,10 +68,13 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
|||||||
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il
|
||||||
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di
|
||||||
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
turno oggi" — resta in `palloni-core.ts` ma non la chiama più nessuno.
|
||||||
- Il conteggio dei turni include anche le proposte non confermate: badge e statistiche
|
|
||||||
possono contare turni mai effettivamente convalidati da nessuno.
|
|
||||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||||
"assente" o "infortunato" per quell'evento.
|
"assente" o "infortunato" per quell'evento.
|
||||||
|
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||||
|
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
|
||||||
|
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||||
|
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
|
||||||
|
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Presenze
|
# Modulo — Presenze
|
||||||
|
|
||||||
**Stato:** implementato (v1.0)
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
**File principali:** `src/lib/presenze.ts`, `src/lib/presenze-mese.ts`, `src/components/crapp/RosaPresenze.tsx`,
|
||||||
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
|
`src/components/crapp/EventoCard.tsx`, `src/routes/api/public/sollecita-presenze.ts`
|
||||||
|
|
||||||
@@ -52,6 +52,14 @@ contaPresenzeGiocatore() / totaliEventiGiocatore() → src/lib/presenze.ts
|
|||||||
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
usePresenzeUltimoMese() → src/lib/presenze-mese.ts
|
||||||
(percentuale ultimi 30gg, da cache già in memoria)
|
(percentuale ultimi 30gg, da cache già in memoria)
|
||||||
|
|
||||||
|
contaPresenzeGiocatore() alimenta il campo `presenze` del `Giocatore` in `useRosa()`, mostrato
|
||||||
|
come StatTile nel profilo e in home (sezione «Colpo d'occhio», `index.tsx`).
|
||||||
|
|
||||||
|
contaPartiteGiocate() è contaPresenzeGiocatore() ristretto alle sole partite (non
|
||||||
|
allenamenti): alimenta `Giocatore.partiteGiocate`, usato nel sottotitolo della classifica
|
||||||
|
interna di Squadra quando si ordina per MVP — un conteggio di eventi generico (allenamenti
|
||||||
|
compresi) sarebbe fuorviante lì, perché l'MVP si vota solo alle partite.
|
||||||
|
|
||||||
--- sollecito (solo admin) ---
|
--- sollecito (solo admin) ---
|
||||||
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
Bottone "Sollecita" (RosaPresenze.tsx) → POST /api/public/sollecita-presenze
|
||||||
↓
|
↓
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Modulo — Profilo Giocatore
|
# Modulo — Profilo Giocatore
|
||||||
|
|
||||||
|
**Stato:** implementato
|
||||||
|
**File principali:** `src/lib/profili.ts`, `src/lib/profili-core.ts`, `src/routes/profilo.tsx`,
|
||||||
|
`src/routes/admin.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Obiettivo
|
## Obiettivo
|
||||||
|
|
||||||
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amministrative e documentali di ciascun membro della squadra.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Modulo — Scout Live
|
# Modulo — Scout Live
|
||||||
|
|
||||||
**Stato:** implementato (v1.0, fix M7 per la persistenza condivisa)
|
**Stato:** implementato (fix M7 per la persistenza condivisa)
|
||||||
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
||||||
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
||||||
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
`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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Modulo — Squadra
|
||||||
|
|
||||||
|
**Stato:** implementato
|
||||||
|
**File principali:** `src/lib/giocatori-squadra.ts`, `src/lib/giocatori-squadra.server.ts`,
|
||||||
|
`src/lib/rosa.ts`, `src/routes/squadra.tsx`, `src/routes/admin.tsx` (sezione rosa)
|
||||||
|
**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/rosa.test.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Tenere l'anagrafica della rosa (nome, numero di maglia, ruolo, chi è collegato a quale
|
||||||
|
account) in un unico posto — `giocatori_squadra` — e farla usare a tutte le schermate che
|
||||||
|
hanno bisogno di sapere "chi c'è in squadra", invece di ciascuna avere la propria copia.
|
||||||
|
Prima di [DD-015](../DESIGN_DECISIONS.md#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database)
|
||||||
|
la lista viveva hardcoded in `src/lib/crapp-data.ts`: aggiungere o disattivare un
|
||||||
|
giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Due letture diverse, per non pagare due volte lo stesso costo
|
||||||
|
|
||||||
|
- **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei
|
||||||
|
giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le
|
||||||
|
liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni.
|
||||||
|
- **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche
|
||||||
|
personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti,
|
||||||
|
partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni,
|
||||||
|
ritardi. Non fa query aggiuntive: combina in un `useMemo` le cache già in memoria di
|
||||||
|
`mvp-voti.ts`, `pagelle.ts`, `cacche.ts`, `palloni.ts`, `infortuni.ts`, `presenze.ts`,
|
||||||
|
`eventi.ts` — la spec di ciascuna di queste statistiche sta nel modulo relativo
|
||||||
|
(`mvp.md`, `pagelle.md`, `palloni.md`, `infortuni.md`, `presenze.md`). `useRosa()` è anche
|
||||||
|
la base di `useIo()` (il giocatore sul dispositivo corrente) e `useObiettivi()`
|
||||||
|
(`obiettivi-squadra.md`).
|
||||||
|
|
||||||
|
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
|
||||||
|
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
|
||||||
|
dagli elenchi correnti.
|
||||||
|
|
||||||
|
## Gestione dati squadra (solo amministratore)
|
||||||
|
|
||||||
|
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
|
||||||
|
|
||||||
|
| Azione | Hook | Effetto |
|
||||||
|
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
|
||||||
|
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||||
|
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
|
||||||
|
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||||
|
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||||
|
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||||
|
|
||||||
|
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||||
|
accesso con Google, per corrispondenza email
|
||||||
|
([DD-018](../DESIGN_DECISIONS.md#dd-018--collegamento-automatico-giocatoreaccount-per-email)).
|
||||||
|
`useCollegaGiocatore()` esiste per completare quel flusso, non per una scelta libera
|
||||||
|
dell'admin.
|
||||||
|
|
||||||
|
Le regole di validazione (`validaDatiSquadra()`, `numeroGiaUsato()`) rispecchiano i vincoli
|
||||||
|
della tabella (numero maglia univoco tra gli attivi, campi obbligatori): l'obiettivo è
|
||||||
|
mostrare un messaggio leggibile invece di far arrivare un errore Postgres grezzo
|
||||||
|
all'amministratore.
|
||||||
|
|
||||||
|
## Classifica interna di Squadra
|
||||||
|
|
||||||
|
La tab "Stats" di `/squadra` mostra una classifica interna ordinabile per 5 criteri
|
||||||
|
(`CriterioClassifica` in `rosa.ts`): presenze, media voto, MVP, palloni, cacche/partita.
|
||||||
|
`classificaRank()` calcola un "dense rank" (a parità di valore stessa posizione, il
|
||||||
|
successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie quale
|
||||||
|
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
|
||||||
|
pagella" per il criterio media voto, non sempre "presenze consecutive").
|
||||||
|
|
||||||
|
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
|
||||||
|
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
|
||||||
|
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limiti noti
|
||||||
|
|
||||||
|
1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i
|
||||||
|
giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts`
|
||||||
|
(`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita
|
||||||
|
nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel
|
||||||
|
Calendario per quei giocatori.
|
||||||
|
2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database
|
||||||
|
non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai
|
||||||
|
dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi
|
||||||
|
comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non
|
||||||
|
quella reale.
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { proietta } from "@/lib/molla";
|
import { proietta } from "@/lib/molla";
|
||||||
|
import { useMotoRidotto } from "@/lib/motion";
|
||||||
|
|
||||||
export type VoceSottosezione = {
|
export type VoceSottosezione = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,8 +21,9 @@ const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.
|
|||||||
*
|
*
|
||||||
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
|
* `variante="sottolineatura"`: pillola piena sulla tab attiva (Squadra e Classifica CSI);
|
||||||
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
|
* su mobile la sola barra tab scorre in orizzontale senza allargare la pagina.
|
||||||
* Default `pillole`: tab a larghezza naturale (Profilo). Il titolo ripetuto sotto la
|
* Default `pillole`: tab a larghezza uguale (basata sulla etichetta più lunga); se non
|
||||||
* barra non viene mai mostrato: l’etichetta è già nella tab.
|
* entrano nella viewport la barra scorre in orizzontale (Profilo). Il titolo ripetuto sotto
|
||||||
|
* la barra non viene mai mostrato: l’etichetta è già nella tab.
|
||||||
*/
|
*/
|
||||||
export function BarraSottosezioni({
|
export function BarraSottosezioni({
|
||||||
voci,
|
voci,
|
||||||
@@ -32,13 +34,17 @@ export function BarraSottosezioni({
|
|||||||
voci: VoceSottosezione[];
|
voci: VoceSottosezione[];
|
||||||
defaultId?: string;
|
defaultId?: string;
|
||||||
variante?: "pillole" | "sottolineatura";
|
variante?: "pillole" | "sottolineatura";
|
||||||
/** Tab a larghezza uguale che riempiono la barra (es. Campionato). */
|
/** Tab a larghezza uguale che riempiono la barra (es. Campionato, Squadra). */
|
||||||
riempiLarghezza?: boolean;
|
riempiLarghezza?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
const [attiva, setAttiva] = useState(defaultId ?? voci[0]?.id ?? "");
|
||||||
const direzione = useRef(0);
|
const direzione = useRef(0);
|
||||||
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
const tabRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
const ridotto = useReducedMotion();
|
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||||
|
// `prefers-reduced-motion`: qui disattiva sia lo swipe orizzontale sia il
|
||||||
|
// blur della barra tab, i due costi maggiori all'ingresso in una pagina con
|
||||||
|
// sottosezioni (Squadra, Campionato, Profilo).
|
||||||
|
const ridotto = useMotoRidotto();
|
||||||
const indice = Math.max(
|
const indice = Math.max(
|
||||||
0,
|
0,
|
||||||
voci.findIndex((v) => v.id === attiva),
|
voci.findIndex((v) => v.id === attiva),
|
||||||
@@ -69,7 +75,8 @@ export function BarraSottosezioni({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 max-w-full border-b border-border bg-background",
|
"min-w-0 max-w-full border-b border-border bg-background",
|
||||||
!sottolineatura && "bg-background/80 pt-3 backdrop-blur-md",
|
!sottolineatura &&
|
||||||
|
(ridotto ? "bg-background pt-3" : "bg-background/80 pt-3 backdrop-blur-md"),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
|
{/* Solo la barra tab può scrollare in orizzontale: non allarga il layout pagina. */}
|
||||||
@@ -83,11 +90,13 @@ export function BarraSottosezioni({
|
|||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Sottosezioni"
|
aria-label="Sottosezioni"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-nowrap",
|
|
||||||
riempiLarghezza
|
riempiLarghezza
|
||||||
? "w-full"
|
? "flex w-full flex-nowrap"
|
||||||
: "w-max min-w-full",
|
: sottolineatura
|
||||||
sottolineatura ? "gap-1 px-2 py-1.5" : "snap-x snap-mandatory gap-1.5 px-5",
|
? "flex w-max min-w-full flex-nowrap"
|
||||||
|
: // Pillole: colonne uguali alla voce più lunga; overflow → scroll sul wrapper.
|
||||||
|
"grid w-max min-w-full grid-flow-col auto-cols-[1fr]",
|
||||||
|
sottolineatura ? "gap-1 px-2 py-1.5" : "gap-1.5 px-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{voci.map((v) => {
|
{voci.map((v) => {
|
||||||
@@ -107,7 +116,11 @@ export function BarraSottosezioni({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
|
"min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors",
|
||||||
riempiLarghezza ? "min-w-0 flex-1" : "shrink-0",
|
riempiLarghezza
|
||||||
|
? "min-w-0 flex-1"
|
||||||
|
: sottolineatura
|
||||||
|
? "shrink-0"
|
||||||
|
: "w-full",
|
||||||
sottolineatura
|
sottolineatura
|
||||||
? cn(
|
? cn(
|
||||||
"rounded-xl px-1 py-2.5 text-center",
|
"rounded-xl px-1 py-2.5 text-center",
|
||||||
@@ -116,7 +129,8 @@ export function BarraSottosezioni({
|
|||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)
|
)
|
||||||
: cn(
|
: cn(
|
||||||
"snap-center rounded-full px-3.5 py-2",
|
"rounded-full py-2 text-center",
|
||||||
|
riempiLarghezza ? "px-2" : "px-3.5",
|
||||||
selezionata
|
selezionata
|
||||||
? "bg-accent text-accent-foreground shadow-pop"
|
? "bg-accent text-accent-foreground shadow-pop"
|
||||||
: "bg-secondary text-muted-foreground",
|
: "bg-secondary text-muted-foreground",
|
||||||
@@ -136,7 +150,9 @@ export function BarraSottosezioni({
|
|||||||
key={voce.id}
|
key={voce.id}
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-label={voce.label}
|
aria-label={voce.label}
|
||||||
drag="x"
|
// Sui device deboli lo swipe orizzontale (pointer listener + hit-testing
|
||||||
|
// ad ogni frame) resta disattivato: si cambia tab solo toccando la barra.
|
||||||
|
drag={ridotto ? false : "x"}
|
||||||
dragConstraints={{ left: 0, right: 0 }}
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
dragElastic={0.12}
|
dragElastic={0.12}
|
||||||
dragMomentum={false}
|
dragMomentum={false}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
|
import { CalendarDays, Home, Trophy, Users } from "lucide-react";
|
||||||
import { motion, useReducedMotion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { molla } from "@/lib/molla";
|
import { molla } from "@/lib/molla";
|
||||||
|
import { useMotoRidotto } from "@/lib/motion";
|
||||||
|
|
||||||
// Quattro voci e non cinque: il profilo sta in alto a destra
|
// Quattro voci e non cinque: il profilo sta in alto a destra
|
||||||
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
|
// nell'intestazione di ogni pagina, dove lo cerca chi arriva da iOS.
|
||||||
@@ -13,7 +14,10 @@ const items = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function BottomNav() {
|
export function BottomNav() {
|
||||||
const ridotto = useReducedMotion();
|
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||||
|
// `prefers-reduced-motion`: qui disattiva sia la molla della capsula sia il
|
||||||
|
// vetro sfocato, i due costi maggiori ad ogni cambio di rotta.
|
||||||
|
const ridotto = useMotoRidotto();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -62,11 +66,21 @@ export function BottomNav() {
|
|||||||
*/}
|
*/}
|
||||||
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
|
{/* `vetro` porta con sé le proprie ombre, quindi niente `shadow-chrome`:
|
||||||
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
|
sarebbero due `box-shadow` sullo stesso elemento e una vincerebbe. */}
|
||||||
<div className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5">
|
<div
|
||||||
|
className="vetro pointer-events-auto mx-auto grid h-[var(--altezza-nav)] max-w-md grid-cols-4 rounded-full border border-white/40 p-1.5"
|
||||||
|
// Su device deboli il backdrop-filter con feTurbulence/feDisplacementMap
|
||||||
|
// (solo Blink, cioè Android) e la molla della capsula sotto sono i due
|
||||||
|
// costi maggiori ad ogni cambio di rotta: `data-leggero` li disattiva.
|
||||||
|
{...(ridotto ? { "data-leggero": "" } : {})}
|
||||||
|
>
|
||||||
{items.map(({ to, label, icon: Icon }) => (
|
{items.map(({ to, label, icon: Icon }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
|
// Il chunk della pagina parte già al tocco (touchstart), non al tap completo:
|
||||||
|
// le 4 rotte sono piccole (8-12KB), nessun costo aggiuntivo di query (nessuna
|
||||||
|
// rotta usa `loader`, quindi non prefetcha dati, solo codice).
|
||||||
|
preload="intent"
|
||||||
activeOptions={{ exact: to === "/" }}
|
activeOptions={{ exact: to === "/" }}
|
||||||
activeProps={{ "aria-current": "page" }}
|
activeProps={{ "aria-current": "page" }}
|
||||||
// Lo stato attivo non è solo colore: è la capsula piena sotto la
|
// Lo stato attivo non è solo colore: è la capsula piena sotto la
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ExternalLink, ShieldAlert, Users2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
|
import { useCsiPartita } from "@/lib/csi-partita";
|
||||||
|
import type { FormazioneSquadra, PrecedentiCsi } from "@/lib/csi-core";
|
||||||
|
|
||||||
|
/** Logo squadra dal portale CSI: nascosto invece che rotto se l'immagine non carica. */
|
||||||
|
export function LogoSquadra({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [errore, setErrore] = useState(false);
|
||||||
|
if (!src || errore) return null;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setErrore(true)}
|
||||||
|
className={cn("shrink-0 rounded-lg object-contain", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadati leggeri sempre disponibili (nessuna fetch aggiuntiva): girone, n° gara, arbitro, referto. */
|
||||||
|
export function MetaPartitaCsi({
|
||||||
|
girone,
|
||||||
|
numeroGara,
|
||||||
|
arbitro,
|
||||||
|
link,
|
||||||
|
}: {
|
||||||
|
girone: string;
|
||||||
|
numeroGara: string;
|
||||||
|
arbitro: string;
|
||||||
|
link: string;
|
||||||
|
}) {
|
||||||
|
const voci = [
|
||||||
|
girone,
|
||||||
|
numeroGara ? `N° gara ${numeroGara}` : "",
|
||||||
|
arbitro ? `Arbitro: ${arbitro}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
if (!voci && !link) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{voci ? <span>{voci}</span> : null}
|
||||||
|
{link ? (
|
||||||
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 font-semibold text-accent"
|
||||||
|
>
|
||||||
|
Referto ufficiale CSI <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{squadra.squadra}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{squadra.titolari.map((g, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
||||||
|
{g.numero}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||||
|
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{squadra.panchina.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-3 text-[11px] font-bold uppercase text-muted-foreground/70">
|
||||||
|
A disposizione
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{squadra.panchina.map((g, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{squadra.staff.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
||||||
|
{squadra.staff.map((s, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{s.nome} · {s.ruolo}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarraPrecedenti({
|
||||||
|
label,
|
||||||
|
noi,
|
||||||
|
avversario,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
noi: number;
|
||||||
|
avversario: number;
|
||||||
|
}) {
|
||||||
|
const totale = noi + avversario || 1;
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
|
<span className="font-bold text-foreground">{noi}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="font-bold text-foreground">{avversario}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div className="bg-accent" style={{ width: `${(noi / totale) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOME_NOI = "CRAP Volley";
|
||||||
|
|
||||||
|
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
|
||||||
|
function TestataSquadre({ avversario }: { avversario: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between text-xs font-bold">
|
||||||
|
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||||
|
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarraProbabilita({
|
||||||
|
avversario,
|
||||||
|
noi,
|
||||||
|
avversarioPct,
|
||||||
|
}: {
|
||||||
|
avversario: string;
|
||||||
|
noi: number;
|
||||||
|
avversarioPct: number;
|
||||||
|
}) {
|
||||||
|
const favoritaNoi = noi >= avversarioPct;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
|
Probabilità di vittoria (calcolo CSI):{" "}
|
||||||
|
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||||
|
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||||
|
</p>
|
||||||
|
<div className="mb-1 flex items-center justify-between text-[11px] font-bold">
|
||||||
|
<span className={favoritaNoi ? "text-success" : "text-muted-foreground"}>{NOME_NOI}</span>
|
||||||
|
<span className={!favoritaNoi ? "text-success" : "text-muted-foreground"}>
|
||||||
|
{avversario}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-7 overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
||||||
|
style={{ width: `${noi}%` }}
|
||||||
|
>
|
||||||
|
{noi.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
||||||
|
style={{ width: `${avversarioPct}%` }}
|
||||||
|
>
|
||||||
|
{avversarioPct.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BloccoPrecedenti({
|
||||||
|
precedenti,
|
||||||
|
avversario,
|
||||||
|
}: {
|
||||||
|
precedenti: PrecedentiCsi;
|
||||||
|
avversario: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<TestataSquadre avversario={avversario} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{precedenti.totale > 0
|
||||||
|
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||||
|
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||||
|
</p>
|
||||||
|
{precedenti.totale > 0 ? (
|
||||||
|
<>
|
||||||
|
<BarraPrecedenti
|
||||||
|
label="vittorie"
|
||||||
|
noi={precedenti.vinteNoi}
|
||||||
|
avversario={precedenti.vinteAvversario}
|
||||||
|
/>
|
||||||
|
<BarraPrecedenti
|
||||||
|
label="in casa"
|
||||||
|
noi={precedenti.casaNoi}
|
||||||
|
avversario={precedenti.casaAvversario}
|
||||||
|
/>
|
||||||
|
<BarraPrecedenti
|
||||||
|
label="fuori"
|
||||||
|
noi={precedenti.fuoriNoi}
|
||||||
|
avversario={precedenti.fuoriAvversario}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{precedenti.probabilitaNoi !== null && precedenti.probabilitaAvversario !== null ? (
|
||||||
|
<BarraProbabilita
|
||||||
|
avversario={avversario}
|
||||||
|
noi={precedenti.probabilitaNoi}
|
||||||
|
avversarioPct={precedenti.probabilitaAvversario}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formazioni e storico scontri diretti per una gara CSI: una fetch in più (cache 6h lato
|
||||||
|
* server), quindi va montato solo quando l'utente apre il dettaglio della gara, mai in una
|
||||||
|
* lista. `matchId` è l'id della gara sul portale CSI (`PartitaCsi.id`). `avversario` serve
|
||||||
|
* solo a etichettare le barre di "Scontri diretti" (nome squadra, non un dato dal CSI).
|
||||||
|
*/
|
||||||
|
export function DettaglioCsiEsteso({
|
||||||
|
matchId,
|
||||||
|
avversario,
|
||||||
|
}: {
|
||||||
|
matchId: string;
|
||||||
|
avversario: string;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useCsiPartita(matchId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||||
|
}
|
||||||
|
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.giornata || data.nota ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{data.formazioni ? (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<ListaFormazione squadra={data.formazioni.noi} />
|
||||||
|
<ListaFormazione squadra={data.formazioni.avversario} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
{data.precedenti ? (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
|
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||||
|
</div>
|
||||||
|
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
|||||||
import type { Evento } from "@/lib/eventi";
|
import type { Evento } from "@/lib/eventi";
|
||||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { dataOggi } from "@/lib/scout-live";
|
import { dataOggi } from "@/lib/scout-live";
|
||||||
|
|
||||||
const tipoMeta = {
|
const tipoMeta = {
|
||||||
@@ -69,7 +69,10 @@ export function EventoCard({
|
|||||||
}) {
|
}) {
|
||||||
const { risposte } = usePresenzeEvento(evento.id);
|
const { risposte } = usePresenzeEvento(evento.id);
|
||||||
const salva = useSalvaPresenza();
|
const salva = useSalvaPresenza();
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
|
||||||
|
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||||
|
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const rosa = squadra.filter((g) => g.attivo);
|
const rosa = squadra.filter((g) => g.attivo);
|
||||||
const stato = io ? risposte[io.id] : undefined;
|
const stato = io ? risposte[io.id] : undefined;
|
||||||
@@ -85,8 +88,7 @@ export function EventoCard({
|
|||||||
const passato = evento.data < dataOggi();
|
const passato = evento.data < dataOggi();
|
||||||
const cliccabile = Boolean(linkTo);
|
const cliccabile = Boolean(linkTo);
|
||||||
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
|
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
|
||||||
const noteCard =
|
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
|
||||||
evento.tipo === "evento" ? evento.note.trim() : "";
|
|
||||||
const stati =
|
const stati =
|
||||||
evento.tipo === "evento"
|
evento.tipo === "evento"
|
||||||
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
|
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
|
||||||
@@ -190,10 +192,7 @@ export function EventoCard({
|
|||||||
|
|
||||||
{io ? (
|
{io ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||||
"pointer-events-auto flex w-full gap-1",
|
|
||||||
metaStato ? "mt-2.5" : "mt-3",
|
|
||||||
)}
|
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="La tua presenza"
|
aria-label="La tua presenza"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Card } from "@/components/crapp/ui-bits";
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import type { Giocatore } from "@/lib/crapp-data";
|
import type { Giocatore } from "@/lib/crapp-data";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||||
|
|
||||||
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||||
@@ -20,7 +20,8 @@ export function Pagelle({
|
|||||||
convocati: Giocatore[];
|
convocati: Giocatore[];
|
||||||
chiuse?: boolean;
|
chiuse?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const { voti: tutti, isPending } = usePagelle();
|
const { voti: tutti, isPending } = usePagelle();
|
||||||
const vota = useVotaPagella();
|
const vota = useVotaPagella();
|
||||||
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { formatData } from "@/lib/crapp-data";
|
|||||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||||
import { useTurniPalloni } from "@/lib/palloni";
|
import { useTurniPalloni } from "@/lib/palloni";
|
||||||
import { useEventi } from "@/lib/eventi";
|
import { useEventi } from "@/lib/eventi";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
|
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
|
||||||
export function PromemoriaPalloni() {
|
export function PromemoriaPalloni() {
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const { turni } = useTurniPalloni();
|
const { turni } = useTurniPalloni();
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventi();
|
||||||
if (!io) return null;
|
if (!io) return null;
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { Avatar } from "@/components/crapp/Avatar";
|
|||||||
import { Barra } from "@/components/motion/Barra";
|
import { Barra } from "@/components/motion/Barra";
|
||||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { intestazioniAutenticate } from "@/lib/auth";
|
import { intestazioniAutenticate } from "@/lib/auth";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import { dataOggi } from "@/lib/scout-live";
|
import { dataOggi } from "@/lib/scout-live";
|
||||||
@@ -18,9 +18,11 @@ const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente
|
|||||||
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
|
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
|
||||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||||
const salva = useSalvaPresenza();
|
const salva = useSalvaPresenza();
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
||||||
|
// niente statistiche di squadra.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const rosa = useRosa();
|
const rosa = useAnagraficaRosa();
|
||||||
const [sollecito, setSollecito] = useState(false);
|
const [sollecito, setSollecito] = useState(false);
|
||||||
const passato = data < dataOggi();
|
const passato = data < dataOggi();
|
||||||
|
|
||||||
@@ -169,7 +171,7 @@ function Gruppo({
|
|||||||
}: {
|
}: {
|
||||||
titolo: string;
|
titolo: string;
|
||||||
n: number;
|
n: number;
|
||||||
lista: Giocatore[];
|
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
|
||||||
attenzione?: boolean;
|
attenzione?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router";
|
|||||||
import { ChevronRight, Lock, Radio } from "lucide-react";
|
import { ChevronRight, Lock, Radio } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
|
* Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando.
|
||||||
@@ -17,7 +17,8 @@ export function ScoutEntry({
|
|||||||
}) {
|
}) {
|
||||||
const { pronto, partita: diOggi } = usePartitaDiOggi();
|
const { pronto, partita: diOggi } = usePartitaDiOggi();
|
||||||
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
|
const partita = eventoId && diOggi?.id !== eventoId ? null : diOggi;
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||||
|
|
||||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { toast } from "sonner";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card } from "@/components/crapp/ui-bits";
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { intestazioniAutenticate } from "@/lib/auth";
|
import { intestazioniAutenticate } from "@/lib/auth";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import {
|
import {
|
||||||
@@ -27,7 +27,8 @@ export function SondaggioCacche({
|
|||||||
dataEvento: string;
|
dataEvento: string;
|
||||||
oraEvento: string;
|
oraEvento: string;
|
||||||
}) {
|
}) {
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const { righe } = useCacche();
|
const { righe } = useCacche();
|
||||||
const salva = useSalvaCacche();
|
const salva = useSalvaCacche();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import { intestazioniAutenticate } from "@/lib/auth";
|
|||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||||
const [aperto, setAperto] = useState(false);
|
const [aperto, setAperto] = useState(false);
|
||||||
const [avviso, setAvviso] = useState(false);
|
const [avviso, setAvviso] = useState(false);
|
||||||
const { salvati, turni, isPending } = useTurniPalloni();
|
const { salvati, turni, isPending } = useTurniPalloni();
|
||||||
const assegna = useAssegnaTurno();
|
const assegna = useAssegnaTurno();
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.nome` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const rosa = squadra.filter((g) => g.attivo);
|
const rosa = squadra.filter((g) => g.attivo);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Crown, Vote } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { usePresenzeEvento } from "@/lib/presenze";
|
import { usePresenzeEvento } from "@/lib/presenze";
|
||||||
import type { Evento } from "@/lib/eventi";
|
import type { Evento } from "@/lib/eventi";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function VotazioneMvp({ evento }: { evento: Evento }) {
|
export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||||
const matchId = evento.id;
|
const matchId = evento.id;
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const voti = useVotiMvp();
|
const voti = useVotiMvp();
|
||||||
const vota = useVotaMvp();
|
const vota = useVotaMvp();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, Crown, Sparkles } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import {
|
import {
|
||||||
categorieSocial,
|
categorieSocial,
|
||||||
conteggioCategoria,
|
conteggioCategoria,
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
|
|
||||||
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
||||||
export function VotoSocial({ matchId }: { matchId: string }) {
|
export function VotoSocial({ matchId }: { matchId: string }) {
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const voti = useVotiSocial();
|
const voti = useVotiSocial();
|
||||||
const vota = useVotaSocial();
|
const vota = useVotaSocial();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { useId, useState, type ComponentPropsWithoutRef, type ReactNode } from "
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { statoMeta, type Stato } from "@/lib/crapp-data";
|
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||||
import { useIo } from "@/lib/rosa";
|
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||||
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
import { Numero } from "@/components/motion/Numero";
|
import { Numero } from "@/components/motion/Numero";
|
||||||
@@ -25,6 +26,7 @@ export function TeamLogo({
|
|||||||
alt="CRAP Volley"
|
alt="CRAP Volley"
|
||||||
width={192}
|
width={192}
|
||||||
height={192}
|
height={192}
|
||||||
|
decoding="async"
|
||||||
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
|
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -49,10 +51,15 @@ export function Card({
|
|||||||
/**
|
/**
|
||||||
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
|
||||||
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
|
||||||
* `PageHeader` per rimetterci il logo — sarebbe un link a sé stessa.
|
* `PageHeader` con il logo che porta alla home.
|
||||||
|
*
|
||||||
|
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
|
||||||
|
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
|
||||||
|
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
|
||||||
|
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
|
||||||
*/
|
*/
|
||||||
export function LinkProfilo() {
|
export function LinkProfilo() {
|
||||||
const g = useIo();
|
const g = useGiocatoreBase();
|
||||||
if (!g) return <TeamLogo className="h-12 w-12" />;
|
if (!g) return <TeamLogo className="h-12 w-12" />;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -60,7 +67,7 @@ export function LinkProfilo() {
|
|||||||
aria-label="Il tuo profilo"
|
aria-label="Il tuo profilo"
|
||||||
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
|
className="premi shrink-0 rounded-2xl ring-2 ring-primary-foreground/30"
|
||||||
>
|
>
|
||||||
<Avatar id={g.id} fallback={g.iniziali} className="h-12 w-12 text-lg" />
|
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -58,6 +58,12 @@ export type BadgeDef = {
|
|||||||
notificaPush?: string;
|
notificaPush?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
|
||||||
|
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
|
||||||
|
*/
|
||||||
|
export const VOTI_MINIMI_PAGELLA = 5;
|
||||||
|
|
||||||
export const badgeDefs: BadgeDef[] = [
|
export const badgeDefs: BadgeDef[] = [
|
||||||
{
|
{
|
||||||
id: "mvp",
|
id: "mvp",
|
||||||
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
|
|||||||
unita: "di media voto",
|
unita: "di media voto",
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
|
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
|
||||||
valore: (g) => g.mediaVoto,
|
valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "palloni",
|
id: "palloni",
|
||||||
@@ -130,7 +136,9 @@ export const badgeSegreti: BadgeDef[] = [
|
|||||||
icon: Ghost,
|
icon: Ghost,
|
||||||
segreto: true,
|
segreto: true,
|
||||||
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||||
valore: (g) => (g.mvp >= 2 && g.mediaVoto >= 8 ? 1 : 0),
|
// Stessa soglia minima di voti del badge Pagellone: sotto VOTI_MINIMI_PAGELLA la media
|
||||||
|
// non è statisticamente significativa, non deve poter sbloccare nemmeno questo segreto.
|
||||||
|
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.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -178,7 +186,7 @@ export const badgeSegreti: BadgeDef[] = [
|
|||||||
id: "s-cacche",
|
id: "s-cacche",
|
||||||
nome: "Trono di ferro",
|
nome: "Trono di ferro",
|
||||||
descrizione:
|
descrizione:
|
||||||
"Almeno 3 partite di campionato affrontate con 3 o più cacche pre-gara. Il bagno del PalaCRAP porta il tuo nome 🚽😂",
|
"Almeno 3 partite affrontate con 3 o più cacche pre-gara (campionato o amichevole, qui non si fanno sconti). Il bagno del PalaCRAP porta il tuo nome 🚽😂",
|
||||||
unita: "partite da record",
|
unita: "partite da record",
|
||||||
icon: Toilet,
|
icon: Toilet,
|
||||||
segreto: true,
|
segreto: true,
|
||||||
|
|||||||
+11
-1
@@ -28,6 +28,8 @@ export type Giocatore = {
|
|||||||
nascita: string;
|
nascita: string;
|
||||||
presenze: number;
|
presenze: number;
|
||||||
totaliEventi: number;
|
totaliEventi: number;
|
||||||
|
/** Solo partite (non allenamenti) a cui era presente o in ritardo. */
|
||||||
|
partiteGiocate: number;
|
||||||
streak: number;
|
streak: number;
|
||||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||||
serieAllenamenti: number;
|
serieAllenamenti: number;
|
||||||
@@ -37,8 +39,12 @@ export type Giocatore = {
|
|||||||
mvp: number;
|
mvp: number;
|
||||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||||
mediaVoto: number;
|
mediaVoto: number;
|
||||||
|
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
|
||||||
|
votiPagella: number;
|
||||||
/** Quante volte ha portato i palloni. */
|
/** Quante volte ha portato i palloni. */
|
||||||
palloni: number;
|
palloni: number;
|
||||||
|
/** Volte consecutive (fino a oggi) in cui ha portato i palloni. */
|
||||||
|
seriePalloni: number;
|
||||||
/** Partite di campionato con almeno 3 cacche dichiarate. */
|
/** Partite di campionato con almeno 3 cacche dichiarate. */
|
||||||
cacche: number;
|
cacche: number;
|
||||||
/** Media di cacche dichiarate per partita. */
|
/** Media di cacche dichiarate per partita. */
|
||||||
@@ -73,7 +79,8 @@ const rosaCSI: Rosa[] = [
|
|||||||
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
|
{ nome: "Giada Valbonesi", nascita: "1994-05-20", ruolo: "Opposto", numero: 10 },
|
||||||
];
|
];
|
||||||
|
|
||||||
function inizialiDa(nome: string) {
|
/** Iniziali da un nome completo (max 2 lettere), es. per il fallback di `Avatar`. */
|
||||||
|
export function inizialiDa(nome: string) {
|
||||||
return nome
|
return nome
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((p) => p[0] ?? "")
|
.map((p) => p[0] ?? "")
|
||||||
@@ -98,15 +105,18 @@ export const giocatori: Giocatore[] = rosaCSI
|
|||||||
iniziali: inizialiDa(r.nome),
|
iniziali: inizialiDa(r.nome),
|
||||||
presenze: 0,
|
presenze: 0,
|
||||||
totaliEventi: 0,
|
totaliEventi: 0,
|
||||||
|
partiteGiocate: 0,
|
||||||
streak: 0,
|
streak: 0,
|
||||||
serieAllenamenti: 0,
|
serieAllenamenti: 0,
|
||||||
seriePartite: 0,
|
seriePartite: 0,
|
||||||
serieConferme: 0,
|
serieConferme: 0,
|
||||||
mvp: 0,
|
mvp: 0,
|
||||||
mediaVoto: 0,
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
infortuni: 0,
|
infortuni: 0,
|
||||||
ritardi: 0,
|
ritardi: 0,
|
||||||
palloni: 0,
|
palloni: 0,
|
||||||
|
seriePalloni: 0,
|
||||||
cacche: 0,
|
cacche: 0,
|
||||||
cacchePartita: 0,
|
cacchePartita: 0,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import type { RigaClassifica } from "./crapp-data";
|
|||||||
export const CSI_BASE = "https://livescore.csibologna.it";
|
export const CSI_BASE = "https://livescore.csibologna.it";
|
||||||
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
|
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
|
||||||
export const CSI_PROJECT_ID = 767;
|
export const CSI_PROJECT_ID = 767;
|
||||||
|
/**
|
||||||
|
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
|
||||||
|
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
|
||||||
|
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
|
||||||
|
*/
|
||||||
|
export const CSI_COPPA_PROJECT_ID = 848;
|
||||||
/** C.R.A.P. Volley sul portale CSI. */
|
/** C.R.A.P. Volley sul portale CSI. */
|
||||||
export const CSI_TEAM_ID = 3359;
|
export const CSI_TEAM_ID = 3359;
|
||||||
export const CSI_GIRONE = "Girone B";
|
export const CSI_GIRONE = "Girone B";
|
||||||
@@ -19,12 +25,23 @@ export const urlClassifica = (projectId = CSI_PROJECT_ID) =>
|
|||||||
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
|
`${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`;
|
||||||
export const urlPartite = (teamId = CSI_TEAM_ID) =>
|
export const urlPartite = (teamId = CSI_TEAM_ID) =>
|
||||||
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
|
`${CSI_BASE}/assets/json/getEventsByTeamId.php?team_id=${teamId}`;
|
||||||
|
/** Info generali della gara (giornata, pubblico ammesso). */
|
||||||
|
export const urlPartitaInfo = (matchId: string) =>
|
||||||
|
`${CSI_BASE}/components/match-main.php?match_id=${matchId}`;
|
||||||
|
/** Formazioni titolari/panchina/staff di entrambe le squadre. */
|
||||||
|
export const urlPartitaFormazioni = (matchId: string) =>
|
||||||
|
`${CSI_BASE}/components/match-players.php?match_id=${matchId}`;
|
||||||
|
/** Storico scontri diretti e probabilità di vittoria calcolata dal CSI. */
|
||||||
|
export const urlPartitaPrecedenti = (matchId: string) =>
|
||||||
|
`${CSI_BASE}/components/match-stats.php?match_id=${matchId}`;
|
||||||
|
|
||||||
export type PartitaCsi = {
|
export type PartitaCsi = {
|
||||||
id: string;
|
id: string;
|
||||||
data: string;
|
data: string;
|
||||||
ora: string;
|
ora: string;
|
||||||
avversario: string;
|
avversario: string;
|
||||||
|
/** URL del logo avversario sul portale CSI, vuoto se non presente. */
|
||||||
|
logoAvversario: string;
|
||||||
casa: boolean;
|
casa: boolean;
|
||||||
/** null finché la gara non è stata giocata. */
|
/** null finché la gara non è stata giocata. */
|
||||||
setNostri: number | null;
|
setNostri: number | null;
|
||||||
@@ -32,13 +49,60 @@ export type PartitaCsi = {
|
|||||||
parziali: Array<[number, number]>;
|
parziali: Array<[number, number]>;
|
||||||
campo: string;
|
campo: string;
|
||||||
competizione: string;
|
competizione: string;
|
||||||
|
/** Es. "Girone B". */
|
||||||
|
girone: string;
|
||||||
|
/** Es. "5/XEB". */
|
||||||
|
numeroGara: string;
|
||||||
|
arbitro: string;
|
||||||
|
/** Referto ufficiale su livescore.csibologna.it. */
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GiocatoreFormazione = { numero: string; nome: string; ruolo: string };
|
||||||
|
export type StaffFormazione = { nome: string; ruolo: string };
|
||||||
|
export type FormazioneSquadra = {
|
||||||
|
squadra: string;
|
||||||
|
titolari: GiocatoreFormazione[];
|
||||||
|
panchina: GiocatoreFormazione[];
|
||||||
|
staff: StaffFormazione[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrecedentiCsi = {
|
||||||
|
totale: number;
|
||||||
|
vinteNoi: number;
|
||||||
|
vinteAvversario: number;
|
||||||
|
casaNoi: number;
|
||||||
|
casaAvversario: number;
|
||||||
|
fuoriNoi: number;
|
||||||
|
fuoriAvversario: number;
|
||||||
|
/** Percentuale 0-100, null se il CSI non la pubblica (es. sport senza storico). */
|
||||||
|
probabilitaNoi: number | null;
|
||||||
|
probabilitaAvversario: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DettaglioPartitaCsi = {
|
||||||
|
/** Es. "2ª Giornata", vuoto se non trovata (fase a eliminazione, coppa...). */
|
||||||
|
giornata: string;
|
||||||
|
/**
|
||||||
|
* Testo libero del CSI sotto l'impianto: a volte "Pubblico non ammesso", a volte il nome
|
||||||
|
* della palestra o altro — non ha un formato fisso, va mostrato così com'è. Vuoto se assente.
|
||||||
|
*/
|
||||||
|
nota: string;
|
||||||
|
/** null se il referto non ha ancora le formazioni (partita non ancora giocata/schierata). */
|
||||||
|
formazioni: { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null;
|
||||||
|
/** null solo se il formato non è riconosciuto; con 0 precedenti i campi restano a 0. */
|
||||||
|
precedenti: PrecedentiCsi | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatiCsi = {
|
export type DatiCsi = {
|
||||||
classifica: RigaClassifica[];
|
classifica: RigaClassifica[];
|
||||||
|
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
|
||||||
|
classificaCoppa: RigaClassifica[];
|
||||||
partite: PartitaCsi[];
|
partite: PartitaCsi[];
|
||||||
girone: string;
|
girone: string;
|
||||||
aggiornato: string;
|
aggiornato: string;
|
||||||
|
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
|
||||||
|
formatoSospetto: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
|
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
|
||||||
@@ -107,11 +171,17 @@ type EventoCsi = {
|
|||||||
id?: number | string;
|
id?: number | string;
|
||||||
start?: string;
|
start?: string;
|
||||||
team1?: string;
|
team1?: string;
|
||||||
|
team1_logo?: string;
|
||||||
team2?: string;
|
team2?: string;
|
||||||
|
team2_logo?: string;
|
||||||
result?: string;
|
result?: string;
|
||||||
partials?: string;
|
partials?: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
|
group?: string;
|
||||||
|
match_number?: string;
|
||||||
|
referees?: string;
|
||||||
|
link?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function punteggio(result: string | undefined): [number, number] | null {
|
function punteggio(result: string | undefined): [number, number] | null {
|
||||||
@@ -146,12 +216,17 @@ export function partiteDaEventi(eventi: unknown): PartitaCsi[] {
|
|||||||
data: dataIso,
|
data: dataIso,
|
||||||
ora: (oraIso ?? "").slice(0, 5),
|
ora: (oraIso ?? "").slice(0, 5),
|
||||||
avversario: casa ? team2 : team1,
|
avversario: casa ? team2 : team1,
|
||||||
|
logoAvversario: (casa ? evento.team2_logo : evento.team1_logo)?.trim() ?? "",
|
||||||
casa,
|
casa,
|
||||||
setNostri: set ? (casa ? set[0] : set[1]) : null,
|
setNostri: set ? (casa ? set[0] : set[1]) : null,
|
||||||
setLoro: set ? (casa ? set[1] : set[0]) : null,
|
setLoro: set ? (casa ? set[1] : set[0]) : null,
|
||||||
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
|
parziali: casa ? tutti : tutti.map(([a, b]) => [b, a] as [number, number]),
|
||||||
campo: testo(evento.field ?? ""),
|
campo: testo(evento.field ?? ""),
|
||||||
competizione: testo(evento.project ?? ""),
|
competizione: testo(evento.project ?? ""),
|
||||||
|
girone: testo(evento.group ?? ""),
|
||||||
|
numeroGara: testo(evento.match_number ?? ""),
|
||||||
|
arbitro: testo(evento.referees ?? ""),
|
||||||
|
link: evento.link?.trim() ?? "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return partite.sort((a, b) => b.data.localeCompare(a.data));
|
return partite.sort((a, b) => b.data.localeCompare(a.data));
|
||||||
@@ -162,15 +237,165 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
|
|||||||
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
|
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
|
||||||
|
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
|
||||||
|
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
|
||||||
|
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
|
||||||
|
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
|
||||||
|
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
|
||||||
|
* silenzioso — vedi "Limiti noti" in docs/modules/collegamento-csi.md.
|
||||||
|
*/
|
||||||
|
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
|
||||||
|
if (!Array.isArray(eventiGrezzi)) return true;
|
||||||
|
return eventiGrezzi.length > 0 && partite.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
|
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
|
||||||
export function matchDaPartitaCsi(p: PartitaCsi) {
|
export function matchDaPartitaCsi(p: PartitaCsi) {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
data: p.data,
|
data: p.data,
|
||||||
avversario: p.avversario,
|
avversario: p.avversario,
|
||||||
|
logoAvversario: p.logoAvversario,
|
||||||
casa: p.casa,
|
casa: p.casa,
|
||||||
setNostri: p.setNostri ?? 0,
|
setNostri: p.setNostri ?? 0,
|
||||||
setLoro: p.setLoro ?? 0,
|
setLoro: p.setLoro ?? 0,
|
||||||
parziali: p.parziali,
|
parziali: p.parziali,
|
||||||
|
campo: p.campo,
|
||||||
|
girone: p.girone,
|
||||||
|
numeroGara: p.numeroGara,
|
||||||
|
arbitro: p.arbitro,
|
||||||
|
link: p.link,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estrae giornata e nota libera (pubblico ammesso, dettagli impianto...) da `match-main.php`. */
|
||||||
|
export function parseInfoPartita(html: string): { giornata: string; nota: string } {
|
||||||
|
const giornataM = /(\d+)\s*<sup>a<\/sup>\s*Giornata/.exec(html);
|
||||||
|
const notaM = /<div class="text-start"><span>([^<]*)<\/span><\/div>/.exec(html);
|
||||||
|
return {
|
||||||
|
giornata: giornataM ? `${giornataM[1]}ª Giornata` : "",
|
||||||
|
nota: notaM ? testo(notaM[1] ?? "") : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Una squadra dentro `match-players.php`: una `<ul class="list-group">` di `<li>`, in
|
||||||
|
* ordine titolari → divisore "A DISPOSIZIONE" → panchina → divisore "STAFF" → staff. I
|
||||||
|
* membri dello staff hanno la stessa struttura dei giocatori ma senza numero di maglia.
|
||||||
|
*/
|
||||||
|
function parseBloccoSquadraFormazione(blocco: string): FormazioneSquadra | null {
|
||||||
|
const nomeM = /team_details\.php\?team_id=\d*"[^>]*>([^<]+)<\/a>/.exec(blocco);
|
||||||
|
const squadra = nomeM ? testo(nomeM[1] ?? "") : "";
|
||||||
|
if (!squadra) return null;
|
||||||
|
|
||||||
|
const titolari: GiocatoreFormazione[] = [];
|
||||||
|
const panchina: GiocatoreFormazione[] = [];
|
||||||
|
const staff: StaffFormazione[] = [];
|
||||||
|
let sezione: "titolari" | "panchina" | "staff" = "titolari";
|
||||||
|
for (const voce of blocco.match(/<li class="list-group-item[\s\S]*?<\/li>/gi) ?? []) {
|
||||||
|
if (/A DISPOSIZIONE/.test(voce)) {
|
||||||
|
sezione = "panchina";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/STAFF/.test(voce)) {
|
||||||
|
sezione = "staff";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nomePersonaM = /person_details\.php\?id=\d+">([^<]+)<\/a>/.exec(voce);
|
||||||
|
if (!nomePersonaM) continue;
|
||||||
|
const nome = testo(nomePersonaM[1] ?? "");
|
||||||
|
const ruoloM = /<div class="small">([^<]*)<\/div>/.exec(voce);
|
||||||
|
const ruolo = ruoloM ? testo(ruoloM[1] ?? "") : "";
|
||||||
|
if (sezione === "staff") {
|
||||||
|
staff.push({ nome, ruolo });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const numeroM = /shrink-8"[^>]*>(\d+)</.exec(voce);
|
||||||
|
const giocatore = { numero: numeroM?.[1] ?? "", nome, ruolo };
|
||||||
|
(sezione === "titolari" ? titolari : panchina).push(giocatore);
|
||||||
|
}
|
||||||
|
return { squadra, titolari, panchina, staff };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formazioni di entrambe le squadre da `match-players.php`. Il markup non distingue
|
||||||
|
* esplicitamente casa/ospite, ma le racchiude in due blocchi separati dal commento
|
||||||
|
* `<!-- SQUADRA OSPITE -->`; qui si riconosce la nostra tramite `isNostraSquadra()`
|
||||||
|
* invece di assumere un ordine fisso.
|
||||||
|
*/
|
||||||
|
export function parseFormazioni(
|
||||||
|
html: string,
|
||||||
|
): { noi: FormazioneSquadra; avversario: FormazioneSquadra } | null {
|
||||||
|
const idx = html.indexOf("SQUADRA OSPITE");
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const primo = parseBloccoSquadraFormazione(html.slice(0, idx));
|
||||||
|
const secondo = parseBloccoSquadraFormazione(html.slice(idx));
|
||||||
|
if (!primo || !secondo) return null;
|
||||||
|
const noi = isNostraSquadra(primo.squadra)
|
||||||
|
? primo
|
||||||
|
: isNostraSquadra(secondo.squadra)
|
||||||
|
? secondo
|
||||||
|
: null;
|
||||||
|
if (!noi) return null;
|
||||||
|
return { noi, avversario: noi === primo ? secondo : primo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storico scontri diretti e probabilità di vittoria da `match-stats.php`. Il blocco di
|
||||||
|
* riepilogo (non la lista partita-per-partita nel modal, meno affidabile da interpretare)
|
||||||
|
* riporta i numeri nell'ordine squadra-casa/squadra-ospite di *questa* gara: qui si
|
||||||
|
* riconosce quale delle due siamo noi tramite `isNostraSquadra()`, come in
|
||||||
|
* `parseFormazioni()`. Con "0 precedenti" il CSI omette del tutto le righe
|
||||||
|
* vittorie/in-casa/fuori (restano a 0) ma la probabilità resta comunque presente.
|
||||||
|
*/
|
||||||
|
export function parsePrecedenti(html: string): PrecedentiCsi | null {
|
||||||
|
const idxCasa = html.indexOf("Squadra casa");
|
||||||
|
const idxOspite = html.indexOf("Squadra ospite");
|
||||||
|
if (idxCasa === -1 || idxOspite === -1) return null;
|
||||||
|
const nomeCasaM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
|
||||||
|
html.slice(idxCasa, idxOspite),
|
||||||
|
);
|
||||||
|
const nomeOspiteM = /team_details\.php\?team_id=\d+"[^>]*>([^<]+)<\/a>/.exec(
|
||||||
|
html.slice(idxOspite),
|
||||||
|
);
|
||||||
|
const nomeCasa = nomeCasaM ? testo(nomeCasaM[1] ?? "") : "";
|
||||||
|
const nomeOspite = nomeOspiteM ? testo(nomeOspiteM[1] ?? "") : "";
|
||||||
|
const noiECasa = isNostraSquadra(nomeCasa);
|
||||||
|
if (!noiECasa && !isNostraSquadra(nomeOspite)) return null;
|
||||||
|
|
||||||
|
const totaleM = /Precedenti:<\/span>\s*<b><a[^>]*>(\d+)<\/a><\/b>/.exec(html);
|
||||||
|
const vittorieM =
|
||||||
|
/<div><b>(\d+)<\/b><\/div>\s*<div[^>]*>vittorie<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||||
|
const inCasaM =
|
||||||
|
/<div><b>(\d+)<\/b><\/div>\s*<div>in casa<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(html);
|
||||||
|
const fuoriM = /<div><b>(\d+)<\/b><\/div>\s*<div>fuori<\/div>\s*<div><b>(\d+)<\/b><\/div>/.exec(
|
||||||
|
html,
|
||||||
|
);
|
||||||
|
// Le due barre "progress-bar" appaiono nell'ordine casa/ospite: il colore (verde/rosso)
|
||||||
|
// segue chi è favorito, non la posizione, quindi qui si usa l'ordine nel markup e non la
|
||||||
|
// classe CSS per capire quale percentuale è "nostra".
|
||||||
|
const percentuali = [...html.matchAll(/progress-bar[\s\S]*?width:\s*([\d.]+)%/g)].map((m) =>
|
||||||
|
Number(m[1]),
|
||||||
|
);
|
||||||
|
const [probCasa, probOspite] = percentuali;
|
||||||
|
|
||||||
|
// I due gruppi di ogni regex sono nell'ordine squadra-casa/squadra-ospite di questa gara:
|
||||||
|
// `casaIndice`/`ospiteIndice` scelgono quale dei due è "noi" in base a `noiECasa`.
|
||||||
|
const casaIndice = noiECasa ? 1 : 2;
|
||||||
|
const ospiteIndice = noiECasa ? 2 : 1;
|
||||||
|
const numero = (m: RegExpExecArray | null, indice: 1 | 2) => (m ? Number(m[indice]) : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totale: totaleM ? Number(totaleM[1]) : 0,
|
||||||
|
vinteNoi: numero(vittorieM, casaIndice),
|
||||||
|
vinteAvversario: numero(vittorieM, ospiteIndice),
|
||||||
|
casaNoi: numero(inCasaM, casaIndice),
|
||||||
|
casaAvversario: numero(inCasaM, ospiteIndice),
|
||||||
|
fuoriNoi: numero(fuoriM, casaIndice),
|
||||||
|
fuoriAvversario: numero(fuoriM, ospiteIndice),
|
||||||
|
probabilitaNoi: (noiECasa ? probCasa : probOspite) ?? null,
|
||||||
|
probabilitaAvversario: (noiECasa ? probOspite : probCasa) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { DettaglioPartitaCsi } from "./csi-core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio di una singola gara CSI (formazioni, storico scontri diretti): una fetch in
|
||||||
|
* più per partita, cache server 6h come `useCsi()`. Va usato solo quando l'utente apre il
|
||||||
|
* dettaglio di una gara, mai in una lista: vedi docs/modules/collegamento-csi.md.
|
||||||
|
*/
|
||||||
|
export function useCsiPartita(matchId: string | undefined) {
|
||||||
|
return useQuery<DettaglioPartitaCsi>({
|
||||||
|
queryKey: ["csi-partita", matchId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/public/csi-partita/${matchId}`);
|
||||||
|
if (!res.ok) throw new Error("csi-partita non disponibile");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
enabled: !!matchId,
|
||||||
|
staleTime: 6 * 60 * 60_000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
+12
-2
@@ -162,8 +162,18 @@ export function useEliminaEvento() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compleanni della rosa, come eventi di calendario dell'anno indicato. */
|
/**
|
||||||
export function compleanniEventi(rosa: Giocatore[], anno = new Date().getFullYear()): Evento[] {
|
* Compleanni della rosa, come eventi di calendario dell'anno indicato.
|
||||||
|
*
|
||||||
|
* Prende solo i tre campi che usa (non l'intero `Giocatore`): il Calendario li
|
||||||
|
* legge da un'anagrafica leggera per non tirarsi dietro tutte le statistiche
|
||||||
|
* (MVP, pagelle, cacche, palloni, infortuni) di `useRosa` solo per le date di
|
||||||
|
* nascita.
|
||||||
|
*/
|
||||||
|
export function compleanniEventi(
|
||||||
|
rosa: Array<Pick<Giocatore, "id" | "nome" | "nascita">>,
|
||||||
|
anno = new Date().getFullYear(),
|
||||||
|
): Evento[] {
|
||||||
return rosa
|
return rosa
|
||||||
.filter((g) => g.nascita)
|
.filter((g) => g.nascita)
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
|
|||||||
+21
-4
@@ -60,6 +60,9 @@ export function useVotaMvp() {
|
|||||||
|
|
||||||
export type ConteggioMvp = { id: string; nome: string; voti: number };
|
export type ConteggioMvp = { id: string; nome: string; voti: number };
|
||||||
|
|
||||||
|
/** Voti minimi in una partita perché l'MVP possa essere assegnato (un solo voto non decide). */
|
||||||
|
export const VOTI_MINIMI_MVP = 2;
|
||||||
|
|
||||||
/** Conteggio voti di una partita, dal più votato. */
|
/** Conteggio voti di una partita, dal più votato. */
|
||||||
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
||||||
const map = new Map<string, ConteggioMvp>();
|
const map = new Map<string, ConteggioMvp>();
|
||||||
@@ -83,8 +86,13 @@ export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
|||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [matchId] of perMatch) {
|
for (const [matchId] of perMatch) {
|
||||||
const top = conteggioPartita(voti, matchId);
|
const top = conteggioPartita(voti, matchId);
|
||||||
// In caso di parità nessun MVP assegnato finché il voto non si sblocca.
|
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
|
||||||
|
if (
|
||||||
|
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||||
|
top.length > 0 &&
|
||||||
|
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||||
|
) {
|
||||||
out[matchId] = top[0]!.nome;
|
out[matchId] = top[0]!.nome;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,13 +103,22 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
|||||||
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
|
return voti.find((v) => v.match_id === matchId && v.votante_id === votanteId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MVP vinti per giocatore, contando una vittoria per partita votata. */
|
/**
|
||||||
|
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
|
||||||
|
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
|
||||||
|
* restituisce una mappa vuota.
|
||||||
|
*/
|
||||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
||||||
const out: Record<string, number> = {};
|
const out: Record<string, number> = {};
|
||||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
const matchIds = new Set(voti.map((v) => v.match_id));
|
||||||
for (const matchId of matchIds) {
|
for (const matchId of matchIds) {
|
||||||
const top = conteggioPartita(voti, matchId);
|
const top = conteggioPartita(voti, matchId);
|
||||||
if (top.length > 0 && (top.length === 1 || top[0]!.voti > top[1]!.voti)) {
|
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||||
|
if (
|
||||||
|
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||||
|
top.length > 0 &&
|
||||||
|
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||||
|
) {
|
||||||
const id = top[0]!.id;
|
const id = top[0]!.id;
|
||||||
out[id] = (out[id] ?? 0) + 1;
|
out[id] = (out[id] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-11
@@ -26,11 +26,32 @@ export type ContestoObiettivi = {
|
|||||||
|
|
||||||
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
|
export const contestoVuoto: ContestoObiettivi = { eventi: [], presenze: {}, pagelle: [] };
|
||||||
|
|
||||||
const MESE = "2026-08";
|
/** Mese corrente in formato "YYYY-MM" (fuso Europe/Rome), per gli obiettivi che si azzerano ogni mese. */
|
||||||
|
function meseCorrente(oggi: Date): string {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number) {
|
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
||||||
|
function aMese(oggi: Date): string {
|
||||||
|
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
||||||
|
oggi,
|
||||||
|
);
|
||||||
|
const preposizione = /^[aeiou]/i.test(nome) ? "ad" : "a";
|
||||||
|
return `${preposizione} ${nome}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ultimo giorno del mese corrente, come "YYYY-MM-DD". */
|
||||||
|
function fineMese(oggi: Date): string {
|
||||||
|
const mese = meseCorrente(oggi);
|
||||||
|
const anno = Number(mese.slice(0, 4));
|
||||||
|
const numeroMese = Number(mese.slice(5, 7));
|
||||||
|
const ultimoGiorno = new Date(Date.UTC(anno, numeroMese, 0)).getUTCDate();
|
||||||
|
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
|
||||||
const delMese = ctx.eventi.filter(
|
const delMese = ctx.eventi.filter(
|
||||||
(e) => e.data.startsWith(MESE) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||||
);
|
);
|
||||||
if (delMese.length === 0 || rosaSize === 0) return 0;
|
if (delMese.length === 0 || rosaSize === 0) return 0;
|
||||||
const posti = delMese.length * rosaSize;
|
const posti = delMese.length * rosaSize;
|
||||||
@@ -56,19 +77,21 @@ function percentualeRisposte(ctx: ContestoObiettivi, rosaSize: number) {
|
|||||||
export function obiettiviSquadra(
|
export function obiettiviSquadra(
|
||||||
rosa: Giocatore[] = giocatori,
|
rosa: Giocatore[] = giocatori,
|
||||||
ctx: ContestoObiettivi = contestoVuoto,
|
ctx: ContestoObiettivi = contestoVuoto,
|
||||||
|
oggi: Date = new Date(),
|
||||||
): ObiettivoSquadra[] {
|
): ObiettivoSquadra[] {
|
||||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||||
const vittorie = ctx.vittorie ?? 0;
|
const vittorie = ctx.vittorie ?? 0;
|
||||||
|
const mese = meseCorrente(oggi);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "o1",
|
id: "o1",
|
||||||
titolo: "90% di presenze ad agosto",
|
titolo: `90% di presenze ${aMese(oggi)}`,
|
||||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||||
valore: percentualePresenzeMese(ctx, rosa.length),
|
valore: percentualePresenzeMese(ctx, rosa.length, mese),
|
||||||
target: 90,
|
target: 90,
|
||||||
unita: "%",
|
unita: "%",
|
||||||
scadenza: "2026-08-31",
|
scadenza: fineMese(oggi),
|
||||||
emoji: "📣",
|
emoji: "📣",
|
||||||
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
||||||
},
|
},
|
||||||
@@ -79,7 +102,7 @@ export function obiettiviSquadra(
|
|||||||
valore: percentualeRisposte(ctx, rosa.length),
|
valore: percentualeRisposte(ctx, rosa.length),
|
||||||
target: 90,
|
target: 90,
|
||||||
unita: "%",
|
unita: "%",
|
||||||
scadenza: "2026-09-30",
|
scadenza: fineMese(oggi),
|
||||||
emoji: "⚡",
|
emoji: "⚡",
|
||||||
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
||||||
},
|
},
|
||||||
@@ -147,7 +170,7 @@ export function obiettiviSquadra(
|
|||||||
id: "o5",
|
id: "o5",
|
||||||
titolo: "10 vittorie in campionato",
|
titolo: "10 vittorie in campionato",
|
||||||
descrizione: "Obiettivo stagionale per il podio",
|
descrizione: "Obiettivo stagionale per il podio",
|
||||||
valore: vittorie,
|
valore: Math.min(vittorie, 10),
|
||||||
target: 10,
|
target: 10,
|
||||||
unita: "vittorie",
|
unita: "vittorie",
|
||||||
emoji: "🏆",
|
emoji: "🏆",
|
||||||
@@ -157,7 +180,7 @@ export function obiettiviSquadra(
|
|||||||
id: "o6",
|
id: "o6",
|
||||||
titolo: "1 evento di squadra al mese",
|
titolo: "1 evento di squadra al mese",
|
||||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(MESE)).length,
|
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
||||||
target: 1,
|
target: 1,
|
||||||
unita: "eventi",
|
unita: "eventi",
|
||||||
emoji: "🍕",
|
emoji: "🍕",
|
||||||
@@ -170,8 +193,8 @@ export function progressoObiettivo(o: ObiettivoSquadra) {
|
|||||||
return Math.min(100, Math.round((o.valore / o.target) * 100));
|
return Math.min(100, Math.round((o.valore / o.target) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi) {
|
export function obiettiviOrdinati(rosa?: Giocatore[], ctx?: ContestoObiettivi, oggi?: Date) {
|
||||||
return obiettiviSquadra(rosa, ctx).sort((a, b) => {
|
return obiettiviSquadra(rosa, ctx, oggi).sort((a, b) => {
|
||||||
const pa = progressoObiettivo(a);
|
const pa = progressoObiettivo(a);
|
||||||
const pb = progressoObiettivo(b);
|
const pb = progressoObiettivo(b);
|
||||||
const ca = pa >= 100 ? 1 : 0;
|
const ca = pa >= 100 ? 1 : 0;
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
|
import { VOTI_MINIMI_PAGELLA } from "./badges";
|
||||||
|
|
||||||
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
/** Voto anonimo da 1 a 10 dato a un compagno per una partita. */
|
||||||
export type VotoPagella = {
|
export type VotoPagella = {
|
||||||
@@ -60,7 +61,11 @@ function arrotonda(n: number) {
|
|||||||
return Math.round(n * 10) / 10;
|
return Math.round(n * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */
|
/**
|
||||||
|
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
|
||||||
|
* di stagione/reset): giocatoreId -> media e numero di voti. Chi non ha ancora ricevuto voti
|
||||||
|
* non compare nella mappa (nessuna divisione per zero): sta al chiamante gestire il fallback.
|
||||||
|
*/
|
||||||
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
|
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
|
||||||
const somma: Record<string, { tot: number; n: number }> = {};
|
const somma: Record<string, { tot: number; n: number }> = {};
|
||||||
for (const v of voti) {
|
for (const v of voti) {
|
||||||
@@ -90,6 +95,15 @@ export function mieiVoti(voti: VotoPagella[], matchId: string, votanteId: string
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media voto da mostrare nella StatTile home (sezione «Colpo d'occhio», DD-028): sotto
|
||||||
|
* `VOTI_MINIMI_PAGELLA` voti ricevuti, `—` invece della media grezza — stessa soglia del
|
||||||
|
* badge Pagellone, non applicata invece dalle StatTile di profilo e squadra.
|
||||||
|
*/
|
||||||
|
export function mediaVotoColpoDOcchio(g: { mediaVoto: number; votiPagella: number }): number | "—" {
|
||||||
|
return g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : "—";
|
||||||
|
}
|
||||||
|
|
||||||
/** Media pagelle di tutta la squadra su tutte le partite. */
|
/** Media pagelle di tutta la squadra su tutte le partite. */
|
||||||
export function mediaSquadra(voti: VotoPagella[]) {
|
export function mediaSquadra(voti: VotoPagella[]) {
|
||||||
if (voti.length === 0) return 0;
|
if (voti.length === 0) return 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { formatData } from "./crapp-data";
|
import { formatData } from "./crapp-data";
|
||||||
import type { Evento } from "./eventi";
|
import type { Evento } from "./eventi";
|
||||||
import { dataOggi } from "./scout-live";
|
import { dataOggi } from "./scout-live";
|
||||||
|
import { aggiornaSerie } from "./serie";
|
||||||
|
|
||||||
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: string | null };
|
||||||
|
|
||||||
@@ -81,6 +82,23 @@ export function conteggioTurni(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Volte consecutive in cui il giocatore ha portato i palloni, contando solo eventi già
|
||||||
|
* passati (stesso criterio `e.data < oggi` di `conteggioTurni`): un evento passato senza
|
||||||
|
* turno confermato non spezza la serie di nessuno, perché non dice ancora chi ha portato
|
||||||
|
* i palloni quella volta.
|
||||||
|
*/
|
||||||
|
export function serieConsecutivaPalloni(
|
||||||
|
giocatoreId: string,
|
||||||
|
turni: Record<string, string>,
|
||||||
|
eventi: Evento[],
|
||||||
|
oggi: string = dataOggi(),
|
||||||
|
): number {
|
||||||
|
return eventiPalloni(eventi)
|
||||||
|
.filter((e) => e.data < oggi && turni[e.id])
|
||||||
|
.reduce((serie, e) => aggiornaSerie(serie, turni[e.id] === giocatoreId), 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
|
export function eventiDelGiorno(eventi: Evento[], isoData: string): Evento[] {
|
||||||
return eventiPalloni(eventi).filter((e) => e.data === isoData);
|
return eventiPalloni(eventi).filter((e) => e.data === isoData);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-3
@@ -14,28 +14,53 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
|
|||||||
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
export type MappaTempiRisposta = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
/** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */
|
||||||
function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string, oggi = dataOggi()) {
|
function eventiContanoPresenze(
|
||||||
|
eventi: Evento[],
|
||||||
|
giocatoreId?: string,
|
||||||
|
oggi = dataOggi(),
|
||||||
|
tipo?: "partita" | "allenamento",
|
||||||
|
) {
|
||||||
return eventi.filter(
|
return eventi.filter(
|
||||||
(e) =>
|
(e) =>
|
||||||
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
(e.tipo === "partita" || e.tipo === "allenamento") &&
|
||||||
|
(tipo === undefined || e.tipo === tipo) &&
|
||||||
e.data < oggi &&
|
e.data < oggi &&
|
||||||
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
|
(giocatoreId === undefined || e.convocati.length === 0 || e.convocati.includes(giocatoreId)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Presenze effettive (presente o in ritardo) su eventi CrAPP. */
|
/**
|
||||||
|
* Presenze effettive (presente o in ritardo) su eventi CrAPP. Senza eventi rilevanti
|
||||||
|
* restituisce 0. `tipo` filtra a un solo tipo di evento (es. solo partite); di default
|
||||||
|
* conta partite e allenamenti insieme, come il resto delle statistiche di presenza.
|
||||||
|
*/
|
||||||
export function contaPresenzeGiocatore(
|
export function contaPresenzeGiocatore(
|
||||||
giocatoreId: string,
|
giocatoreId: string,
|
||||||
eventi: Evento[],
|
eventi: Evento[],
|
||||||
presenze: MappaPresenze,
|
presenze: MappaPresenze,
|
||||||
oggi: string = dataOggi(),
|
oggi: string = dataOggi(),
|
||||||
|
tipo?: "partita" | "allenamento",
|
||||||
): number {
|
): number {
|
||||||
return eventiContanoPresenze(eventi, giocatoreId, oggi).filter((e) => {
|
return eventiContanoPresenze(eventi, giocatoreId, oggi, tipo).filter((e) => {
|
||||||
const stato = presenze[e.id]?.[giocatoreId];
|
const stato = presenze[e.id]?.[giocatoreId];
|
||||||
return stato === "presente" || stato === "ritardo";
|
return stato === "presente" || stato === "ritardo";
|
||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partite (non allenamenti) a cui il giocatore era presente o in ritardo: il dato giusto
|
||||||
|
* per contesti legati alle prestazioni in campo (es. MVP), a differenza di
|
||||||
|
* `totaliEventiGiocatore()` che è il denominatore delle presenze e include gli allenamenti.
|
||||||
|
*/
|
||||||
|
export function contaPartiteGiocate(
|
||||||
|
giocatoreId: string,
|
||||||
|
eventi: Evento[],
|
||||||
|
presenze: MappaPresenze,
|
||||||
|
oggi: string = dataOggi(),
|
||||||
|
): number {
|
||||||
|
return contaPresenzeGiocatore(giocatoreId, eventi, presenze, oggi, "partita");
|
||||||
|
}
|
||||||
|
|
||||||
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
|
/** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */
|
||||||
export function totaliEventiGiocatore(
|
export function totaliEventiGiocatore(
|
||||||
giocatoreId: string,
|
giocatoreId: string,
|
||||||
|
|||||||
+96
-4
@@ -4,12 +4,13 @@ import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
|||||||
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
||||||
import { mediePagelle, usePagelle } from "./pagelle";
|
import { mediePagelle, usePagelle } from "./pagelle";
|
||||||
import { statisticheCacche, useCacche } from "./cacche";
|
import { statisticheCacche, useCacche } from "./cacche";
|
||||||
import { conteggioTurni } from "./palloni-core";
|
import { conteggioTurni, serieConsecutivaPalloni } from "./palloni-core";
|
||||||
import { useTurniPalloni } from "./palloni";
|
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 {
|
import {
|
||||||
|
contaPartiteGiocate,
|
||||||
contaPresenzeGiocatore,
|
contaPresenzeGiocatore,
|
||||||
serieConferme,
|
serieConferme,
|
||||||
serieConsecutiva,
|
serieConsecutiva,
|
||||||
@@ -24,6 +25,31 @@ function iniziali(nome: string, cognome: string): string {
|
|||||||
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
|
return `${nome[0] ?? ""}${cognome[0] ?? ""}`.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
|
||||||
|
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
|
||||||
|
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e
|
||||||
|
* il relativo `useMemo` solo per l'anagrafica.
|
||||||
|
*/
|
||||||
|
export function useAnagraficaRosa(): Array<
|
||||||
|
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||||
|
> {
|
||||||
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
squadra
|
||||||
|
.filter((g) => g.attivo)
|
||||||
|
.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
nome: nomeCompleto(g),
|
||||||
|
ruolo: g.ruolo,
|
||||||
|
numero: g.numero,
|
||||||
|
nascita: nascitaPerId[g.id] ?? "",
|
||||||
|
})),
|
||||||
|
[squadra],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||||
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
|
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
|
||||||
@@ -36,7 +62,7 @@ export function useRosa(): Giocatore[] {
|
|||||||
const voti = useVotiMvp();
|
const voti = useVotiMvp();
|
||||||
const { voti: pagelle } = usePagelle();
|
const { voti: pagelle } = usePagelle();
|
||||||
const { righe: cacche } = useCacche();
|
const { righe: cacche } = useCacche();
|
||||||
const { turni } = 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, tempi } = useRispostePresenze();
|
||||||
@@ -46,7 +72,10 @@ export function useRosa(): Giocatore[] {
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const medie = mediePagelle(pagelle);
|
const medie = mediePagelle(pagelle);
|
||||||
const statCacche = statisticheCacche(cacche);
|
const statCacche = statisticheCacche(cacche);
|
||||||
const palloni = conteggioTurni(turni, eventi);
|
// Solo i turni confermati, non le proposte automatiche di completaTurni(): il badge deve
|
||||||
|
// premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha
|
||||||
|
// scelto per un evento passato senza che nessuno confermasse nulla.
|
||||||
|
const palloni = conteggioTurni(turniSalvati, eventi);
|
||||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
|
const mvpVinti = mvpVintiPerGiocatore(votiMvp);
|
||||||
|
|
||||||
return squadra
|
return squadra
|
||||||
@@ -60,19 +89,82 @@ 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),
|
||||||
|
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||||
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),
|
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,
|
||||||
palloni: palloni[g.id] ?? 0,
|
palloni: palloni[g.id] ?? 0,
|
||||||
|
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||||
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, tempi]);
|
}, [
|
||||||
|
squadra,
|
||||||
|
votiMvp,
|
||||||
|
pagelle,
|
||||||
|
cacche,
|
||||||
|
turniSalvati,
|
||||||
|
infortuni,
|
||||||
|
ritardi,
|
||||||
|
eventi,
|
||||||
|
mappaPresenze,
|
||||||
|
tempi,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Criteri di ordinamento della classifica interna di Squadra. */
|
||||||
|
export type CriterioClassifica = "presenze" | "mediaVoto" | "mvp" | "palloni" | "cacchePartita";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio mostrato sotto il nome nella classifica interna, coerente col criterio
|
||||||
|
* selezionato: mostrare sempre le "presenze consecutive" aveva senso solo per Presenze,
|
||||||
|
* per gli altri criteri era un dato fuorviante perché scollegato dal valore in classifica.
|
||||||
|
*/
|
||||||
|
export function dettaglioClassifica(
|
||||||
|
g: {
|
||||||
|
streak: number;
|
||||||
|
votiPagella: number;
|
||||||
|
partiteGiocate: number;
|
||||||
|
cacche: number;
|
||||||
|
seriePalloni: number;
|
||||||
|
},
|
||||||
|
criterio: CriterioClassifica,
|
||||||
|
): string {
|
||||||
|
switch (criterio) {
|
||||||
|
case "mediaVoto":
|
||||||
|
return `${g.votiPagella} voti pagella`;
|
||||||
|
case "mvp":
|
||||||
|
return `${g.partiteGiocate} partite giocate`;
|
||||||
|
case "cacchePartita":
|
||||||
|
return `${g.cacche} giornate top`;
|
||||||
|
case "palloni":
|
||||||
|
return `${g.seriePalloni} volte consecutive`;
|
||||||
|
default:
|
||||||
|
return `${g.streak} presenze consecutive`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posizione in classifica ("dense rank"): a parità di valore i giocatori condividono la
|
||||||
|
* stessa posizione e il numero successivo non salta (1, 1, 2 — non 1, 1, 3). `valori` deve
|
||||||
|
* essere già ordinato in modo decrescente, coerente con l'ordine visualizzato.
|
||||||
|
*/
|
||||||
|
export function classificaRank(valori: number[]): number[] {
|
||||||
|
const rank: number[] = [];
|
||||||
|
for (let i = 0; i < valori.length; i++) {
|
||||||
|
if (i > 0 && valori[i] === valori[i - 1]) {
|
||||||
|
rank.push(rank[i - 1] ?? 1);
|
||||||
|
} else {
|
||||||
|
rank.push((rank[i - 1] ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rank;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
/** Il giocatore selezionato sul dispositivo, con le statistiche complete. */
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
|
|||||||
import { Route as ScoutRouteImport } from './routes/scout'
|
import { Route as ScoutRouteImport } from './routes/scout'
|
||||||
import { Route as SquadraRouteImport } from './routes/squadra'
|
import { Route as SquadraRouteImport } from './routes/squadra'
|
||||||
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
|
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
|
||||||
|
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||||
@@ -26,6 +27,7 @@ import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/pub
|
|||||||
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
|
||||||
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
import { Route as ApiPublicPushSubscribeRouteImport } from './routes/api/public/push-subscribe'
|
||||||
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
|
import { Route as ApiPublicSollecitaPresenzeRouteImport } from './routes/api/public/sollecita-presenze'
|
||||||
|
import { Route as ApiPublicCsiPartitaIdRouteImport } from './routes/api/public/csi-partita.$id'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@@ -77,6 +79,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
|
|||||||
path: '/allenamento/$id',
|
path: '/allenamento/$id',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
|
||||||
|
id: '/partita-csi/$id',
|
||||||
|
path: '/partita-csi/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const PartitaIdRoute = PartitaIdRouteImport.update({
|
const PartitaIdRoute = PartitaIdRouteImport.update({
|
||||||
id: '/partita/$id',
|
id: '/partita/$id',
|
||||||
path: '/partita/$id',
|
path: '/partita/$id',
|
||||||
@@ -114,6 +121,11 @@ const ApiPublicSollecitaPresenzeRoute =
|
|||||||
path: '/api/public/sollecita-presenze',
|
path: '/api/public/sollecita-presenze',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicCsiPartitaIdRoute = ApiPublicCsiPartitaIdRouteImport.update({
|
||||||
|
id: '/api/public/csi-partita/$id',
|
||||||
|
path: '/api/public/csi-partita/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -126,6 +138,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
@@ -133,6 +146,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||||
|
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
@@ -145,6 +159,7 @@ export interface FileRoutesByTo {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
@@ -152,6 +167,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||||
|
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -165,6 +181,7 @@ export interface FileRoutesById {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
@@ -172,6 +189,7 @@ export interface FileRoutesById {
|
|||||||
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
'/api/public/push-config': typeof ApiPublicPushConfigRoute
|
||||||
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
'/api/public/push-subscribe': typeof ApiPublicPushSubscribeRoute
|
||||||
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
'/api/public/sollecita-presenze': typeof ApiPublicSollecitaPresenzeRoute
|
||||||
|
'/api/public/csi-partita/$id': typeof ApiPublicCsiPartitaIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -186,6 +204,7 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
@@ -193,6 +212,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
| '/api/public/sollecita-presenze'
|
| '/api/public/sollecita-presenze'
|
||||||
|
| '/api/public/csi-partita/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -205,6 +225,7 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
@@ -212,6 +233,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
| '/api/public/sollecita-presenze'
|
| '/api/public/sollecita-presenze'
|
||||||
|
| '/api/public/csi-partita/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
@@ -224,6 +246,7 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
@@ -231,6 +254,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/public/push-config'
|
| '/api/public/push-config'
|
||||||
| '/api/public/push-subscribe'
|
| '/api/public/push-subscribe'
|
||||||
| '/api/public/sollecita-presenze'
|
| '/api/public/sollecita-presenze'
|
||||||
|
| '/api/public/csi-partita/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -244,6 +268,7 @@ export interface RootRouteChildren {
|
|||||||
ScoutRoute: typeof ScoutRoute
|
ScoutRoute: typeof ScoutRoute
|
||||||
SquadraRoute: typeof SquadraRoute
|
SquadraRoute: typeof SquadraRoute
|
||||||
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
||||||
|
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
|
||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
@@ -251,6 +276,7 @@ export interface RootRouteChildren {
|
|||||||
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
|
||||||
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
ApiPublicPushSubscribeRoute: typeof ApiPublicPushSubscribeRoute
|
||||||
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
|
ApiPublicSollecitaPresenzeRoute: typeof ApiPublicSollecitaPresenzeRoute
|
||||||
|
ApiPublicCsiPartitaIdRoute: typeof ApiPublicCsiPartitaIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -325,6 +351,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AllenamentoIdRouteImport
|
preLoaderRoute: typeof AllenamentoIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/partita-csi/$id': {
|
||||||
|
id: '/partita-csi/$id'
|
||||||
|
path: '/partita-csi/$id'
|
||||||
|
fullPath: '/partita-csi/$id'
|
||||||
|
preLoaderRoute: typeof PartitaCsiIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/partita/$id': {
|
'/partita/$id': {
|
||||||
id: '/partita/$id'
|
id: '/partita/$id'
|
||||||
path: '/partita/$id'
|
path: '/partita/$id'
|
||||||
@@ -374,6 +407,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicSollecitaPresenzeRouteImport
|
preLoaderRoute: typeof ApiPublicSollecitaPresenzeRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/public/csi-partita/$id': {
|
||||||
|
id: '/api/public/csi-partita/$id'
|
||||||
|
path: '/api/public/csi-partita/$id'
|
||||||
|
fullPath: '/api/public/csi-partita/$id'
|
||||||
|
preLoaderRoute: typeof ApiPublicCsiPartitaIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +428,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ScoutRoute: ScoutRoute,
|
ScoutRoute: ScoutRoute,
|
||||||
SquadraRoute: SquadraRoute,
|
SquadraRoute: SquadraRoute,
|
||||||
AllenamentoIdRoute: AllenamentoIdRoute,
|
AllenamentoIdRoute: AllenamentoIdRoute,
|
||||||
|
PartitaCsiIdRoute: PartitaCsiIdRoute,
|
||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
@@ -395,6 +436,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
|
||||||
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
ApiPublicPushSubscribeRoute: ApiPublicPushSubscribeRoute,
|
||||||
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
|
ApiPublicSollecitaPresenzeRoute: ApiPublicSollecitaPresenzeRoute,
|
||||||
|
ApiPublicCsiPartitaIdRoute: ApiPublicCsiPartitaIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
parseFormazioni,
|
||||||
|
parseInfoPartita,
|
||||||
|
parsePrecedenti,
|
||||||
|
urlPartitaFormazioni,
|
||||||
|
urlPartitaInfo,
|
||||||
|
urlPartitaPrecedenti,
|
||||||
|
type DettaglioPartitaCsi,
|
||||||
|
} from "@/lib/csi-core";
|
||||||
|
|
||||||
|
const SCADENZA_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Una gara giocata non cambia più: la cache per-partita non ha bisogno di scadere mai
|
||||||
|
// per i dati storici, ma teniamo la stessa finestra di /api/public/csi per semplicità e
|
||||||
|
// per non tenere in memoria per sempre partite che nessuno riguarda più. Stessa cache in
|
||||||
|
// memoria del processo, stesso limite (si perde ai cold start): vedi limite 3 in
|
||||||
|
// docs/modules/collegamento-csi.md.
|
||||||
|
const cache = new Map<string, { dati: DettaglioPartitaCsi; scadenza: number }>();
|
||||||
|
|
||||||
|
async function scarica(url: string): Promise<string> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "CrAPP/1.0 (+https://crapvolley.it)" },
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`CSI ${res.status} su ${url}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiDettaglioPartita(matchId: string): Promise<DettaglioPartitaCsi> {
|
||||||
|
const [main, players, stats] = await Promise.all([
|
||||||
|
scarica(urlPartitaInfo(matchId)),
|
||||||
|
scarica(urlPartitaFormazioni(matchId)),
|
||||||
|
scarica(urlPartitaPrecedenti(matchId)),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...parseInfoPartita(main),
|
||||||
|
formazioni: parseFormazioni(players),
|
||||||
|
precedenti: parsePrecedenti(stats),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/public/csi-partita/$id")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async ({ params }) => {
|
||||||
|
const matchId = params.id;
|
||||||
|
const voce = cache.get(matchId);
|
||||||
|
if (voce && Date.now() < voce.scadenza) return Response.json(voce.dati);
|
||||||
|
try {
|
||||||
|
const dati = await leggiDettaglioPartita(matchId);
|
||||||
|
cache.set(matchId, { dati, scadenza: Date.now() + SCADENZA_MS });
|
||||||
|
return Response.json(dati);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("csi-partita", matchId, error);
|
||||||
|
// Meglio un dato vecchio che nessun dato, come /api/public/csi.
|
||||||
|
if (voce) return Response.json(voce.dati);
|
||||||
|
return new Response("CSI non raggiungibile", { status: 503 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
|
CSI_COPPA_PROJECT_ID,
|
||||||
CSI_GIRONE,
|
CSI_GIRONE,
|
||||||
parseClassifica,
|
parseClassifica,
|
||||||
partiteDaEventi,
|
partiteDaEventi,
|
||||||
|
partiteFormatoSospetto,
|
||||||
urlClassifica,
|
urlClassifica,
|
||||||
urlPartite,
|
urlPartite,
|
||||||
type DatiCsi,
|
type DatiCsi,
|
||||||
@@ -27,13 +29,39 @@ async function scarica(url: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function leggiCsi(): Promise<DatiCsi> {
|
async function leggiCsi(): Promise<DatiCsi> {
|
||||||
const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]);
|
const [html, htmlCoppa, json] = await Promise.all([
|
||||||
|
scarica(urlClassifica()),
|
||||||
|
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
|
||||||
|
// come classifica/partite del girone (vedi il controllo sotto).
|
||||||
|
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
|
||||||
|
scarica(urlPartite()),
|
||||||
|
]);
|
||||||
const classifica = parseClassifica(html);
|
const classifica = parseClassifica(html);
|
||||||
const partite = partiteDaEventi(JSON.parse(json));
|
const classificaCoppa = parseClassifica(htmlCoppa);
|
||||||
|
const eventiGrezzi = JSON.parse(json);
|
||||||
|
const partite = partiteDaEventi(eventiGrezzi);
|
||||||
if (classifica.length === 0 && partite.length === 0) {
|
if (classifica.length === 0 && partite.length === 0) {
|
||||||
throw new Error("CSI: risposta senza classifica né partite");
|
throw new Error("CSI: risposta senza classifica né partite");
|
||||||
}
|
}
|
||||||
return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() };
|
// La classifica basta a evitare l'errore sopra, ma se solo le partite si rompono (formato di
|
||||||
|
// getEventsByTeamId.php cambiato) la route tornerebbe comunque 200 senza che nessuno se ne
|
||||||
|
// accorga: le vittorie degli obiettivi di squadra resterebbero ferme a 0% in silenzio. Oltre
|
||||||
|
// al log server, il flag arriva fino a `/classifica` (badge discreto) perché qualcuno se ne
|
||||||
|
// accorga anche senza guardare i log.
|
||||||
|
const formatoSospetto = partiteFormatoSospetto(eventiGrezzi, partite);
|
||||||
|
if (formatoSospetto) {
|
||||||
|
console.error(
|
||||||
|
"csi: il formato di getEventsByTeamId.php sembra cambiato, nessuna partita riconosciuta",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
classifica,
|
||||||
|
classificaCoppa,
|
||||||
|
partite,
|
||||||
|
girone: CSI_GIRONE,
|
||||||
|
aggiornato: new Date().toISOString(),
|
||||||
|
formatoSospetto,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/public/csi")({
|
export const Route = createFileRoute("/api/public/csi")({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
useCollegaGiocatore,
|
useCollegaGiocatore,
|
||||||
useGiocatoriSquadra,
|
useGiocatoriSquadra,
|
||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
import { impostaGiocatore, resetGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
|
import { impostaGiocatore, resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
export const Route = createFileRoute("/benvenuto")({
|
export const Route = createFileRoute("/benvenuto")({
|
||||||
validateSearch: (search: Record<string, unknown>): { next?: string } => {
|
validateSearch: (search: Record<string, unknown>): { next?: string } => {
|
||||||
@@ -38,7 +38,8 @@ export const Route = createFileRoute("/benvenuto")({
|
|||||||
function Benvenuto() {
|
function Benvenuto() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { next } = Route.useSearch();
|
const { next } = Route.useSearch();
|
||||||
const giocatore = useGiocatoreCorrente();
|
// Solo verità (`!!giocatore`): `useGiocatoreBase` (anagrafica) basta, niente statistiche.
|
||||||
|
const giocatore = useGiocatoreBase();
|
||||||
const { pronta, utenteId, emailUtente } = useSessione();
|
const { pronta, utenteId, emailUtente } = useSessione();
|
||||||
const { righe, daDatabase } = useGiocatoriSquadra();
|
const { righe, daDatabase } = useGiocatoriSquadra();
|
||||||
const collega = useCollegaGiocatore();
|
const collega = useCollegaGiocatore();
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useEffect, useState } from "react";
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { molla, proietta } from "@/lib/molla";
|
import { molla, proietta } from "@/lib/molla";
|
||||||
|
import { useMotoRidotto } from "@/lib/motion";
|
||||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -114,8 +115,10 @@ function Calendario() {
|
|||||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventi();
|
||||||
const rosa = useRosa();
|
const rosa = useAnagraficaRosa();
|
||||||
const ridotto = useReducedMotion();
|
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||||
|
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||||
|
const ridotto = useMotoRidotto();
|
||||||
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
|
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
|
||||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||||
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||||
@@ -197,7 +200,7 @@ function Calendario() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={mesePrefix}
|
key={mesePrefix}
|
||||||
custom={direzione}
|
custom={direzione}
|
||||||
drag="x"
|
drag={ridotto ? false : "x"}
|
||||||
dragConstraints={{ left: 0, right: 0 }}
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
dragElastic={0.18}
|
dragElastic={0.18}
|
||||||
dragMomentum={false}
|
dragMomentum={false}
|
||||||
|
|||||||
+124
-76
@@ -1,7 +1,8 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { ChevronRight, RefreshCw } from "lucide-react";
|
import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData, type RigaClassifica } from "@/lib/crapp-data";
|
||||||
import { PageHeader } from "@/components/crapp/ui-bits";
|
import { PageHeader } from "@/components/crapp/ui-bits";
|
||||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||||
import { useScoutMatches } from "@/lib/scout-store";
|
import { useScoutMatches } from "@/lib/scout-store";
|
||||||
@@ -9,6 +10,7 @@ import { useCsi } from "@/lib/csi";
|
|||||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||||
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
||||||
import { useEventi } from "@/lib/eventi";
|
import { useEventi } from "@/lib/eventi";
|
||||||
|
import { LogoSquadra } from "@/components/crapp/DettaglioCsi";
|
||||||
|
|
||||||
const TAB_CLASSIFICA = ["classifica", "storico"] as const;
|
const TAB_CLASSIFICA = ["classifica", "storico"] as const;
|
||||||
type TabClassifica = (typeof TAB_CLASSIFICA)[number];
|
type TabClassifica = (typeof TAB_CLASSIFICA)[number];
|
||||||
@@ -36,6 +38,44 @@ export const Route = createFileRoute("/classifica")({
|
|||||||
component: Classifica,
|
component: Classifica,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||||
|
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
||||||
|
<span>#</span>
|
||||||
|
<span>Squadra</span>
|
||||||
|
<span className="text-center">G</span>
|
||||||
|
<span className="text-center">Set</span>
|
||||||
|
<span className="text-center">Pt</span>
|
||||||
|
</div>
|
||||||
|
{righe.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{vuoto}</p>
|
||||||
|
) : (
|
||||||
|
righe.map((r) => {
|
||||||
|
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.pos}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
||||||
|
noi && "bg-accent/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("font-display text-base", noi && "text-accent")}>{r.pos}</span>
|
||||||
|
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>{r.squadra}</span>
|
||||||
|
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
|
||||||
|
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
||||||
|
{r.setFatti}:{r.setSubiti}
|
||||||
|
</span>
|
||||||
|
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatAggiornamento(iso: string) {
|
function formatAggiornamento(iso: string) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const oggi = new Date().toDateString() === d.toDateString();
|
const oggi = new Date().toDateString() === d.toDateString();
|
||||||
@@ -48,17 +88,21 @@ function Classifica() {
|
|||||||
const scoutMatches = useScoutMatches();
|
const scoutMatches = useScoutMatches();
|
||||||
const { data: csi } = useCsi();
|
const { data: csi } = useCsi();
|
||||||
const votiMvp = useVotiMvp();
|
const votiMvp = useVotiMvp();
|
||||||
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
|
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventi();
|
||||||
|
|
||||||
const classifica = csi?.classifica ?? [];
|
const classifica = useMemo(() => csi?.classifica ?? [], [csi]);
|
||||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]);
|
||||||
const eventoIdPerData = new Map(
|
|
||||||
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
|
const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]);
|
||||||
|
const eventoIdPerData = useMemo(
|
||||||
|
() => new Map(eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id])),
|
||||||
|
[eventi],
|
||||||
);
|
);
|
||||||
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
|
// I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
|
||||||
|
const tuttiMatch = useMemo(() => {
|
||||||
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
|
const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
|
||||||
const tuttiMatch = csiGiocate.length
|
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||||
|
return csiGiocate.length
|
||||||
? csiGiocate.map((p) => {
|
? csiGiocate.map((p) => {
|
||||||
const m = matchDaPartitaCsi(p);
|
const m = matchDaPartitaCsi(p);
|
||||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||||
@@ -67,6 +111,7 @@ function Classifica() {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
data: m.data,
|
data: m.data,
|
||||||
avversario: m.avversario,
|
avversario: m.avversario,
|
||||||
|
logoAvversario: "",
|
||||||
casa: m.casa,
|
casa: m.casa,
|
||||||
setNostri: m.setNostri,
|
setNostri: m.setNostri,
|
||||||
setLoro: m.setLoro,
|
setLoro: m.setLoro,
|
||||||
@@ -74,78 +119,47 @@ function Classifica() {
|
|||||||
mvp: mvpPerData(m.data),
|
mvp: mvpPerData(m.data),
|
||||||
scout: true,
|
scout: true,
|
||||||
}));
|
}));
|
||||||
|
}, [csi, scoutMatches, mvpPerMatch, eventoIdPerData]);
|
||||||
|
|
||||||
return (
|
// Ogni tab è memoizzata: un refetch in background di uno solo dei dati (CSI,
|
||||||
|
// scout, eventi, voti MVP) non deve ricostruire il JSX dell'altra tab.
|
||||||
|
const contenutoClassifica = useMemo(
|
||||||
|
() => (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<div
|
||||||
titolo="Campionato"
|
className={cn(
|
||||||
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
|
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
|
||||||
/>
|
csi?.formatoSospetto
|
||||||
|
? "bg-warning/15 text-warning"
|
||||||
<BarraSottosezioni
|
: "bg-secondary text-muted-foreground",
|
||||||
defaultId={tab ?? "classifica"}
|
)}
|
||||||
variante="sottolineatura"
|
>
|
||||||
riempiLarghezza
|
{csi?.formatoSospetto ? (
|
||||||
voci={[
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden />
|
||||||
{
|
) : (
|
||||||
id: "classifica",
|
<RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden />
|
||||||
label: "Classifica",
|
)}
|
||||||
contenuto: (
|
{csi?.formatoSospetto
|
||||||
<>
|
? "Il portale CSI potrebbe aver cambiato formato: dati da verificare."
|
||||||
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
|
: csi
|
||||||
<RefreshCw className="h-3.5 w-3.5 text-accent" />
|
|
||||||
{csi
|
|
||||||
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
|
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
|
||||||
: "Dati CSI in arrivo"}
|
: "Dati CSI in arrivo"}
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
{classificaCoppa.length > 0 ? (
|
||||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
<>
|
||||||
<span>#</span>
|
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
|
||||||
<span>Squadra</span>
|
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
||||||
<span className="text-center">G</span>
|
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
|
||||||
<span className="text-center">Set</span>
|
</>
|
||||||
<span className="text-center">Pt</span>
|
) : null}
|
||||||
</div>
|
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
|
||||||
{classifica.length === 0 ? (
|
|
||||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">
|
|
||||||
Classifica non ancora disponibile.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
classifica.map((r) => {
|
|
||||||
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={r.pos}
|
|
||||||
className={cn(
|
|
||||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
|
||||||
noi && "bg-accent/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cn("font-display text-base", noi && "text-accent")}>
|
|
||||||
{r.pos}
|
|
||||||
</span>
|
|
||||||
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>
|
|
||||||
{r.squadra}
|
|
||||||
</span>
|
|
||||||
<span className="text-center text-xs text-muted-foreground">
|
|
||||||
{r.giocate}
|
|
||||||
</span>
|
|
||||||
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
|
||||||
{r.setFatti}:{r.setSubiti}
|
|
||||||
</span>
|
|
||||||
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
[csi, classifica, classificaCoppa],
|
||||||
{
|
);
|
||||||
id: "storico",
|
|
||||||
label: "Storico partite",
|
const contenutoStorico = useMemo(
|
||||||
contenuto:
|
() =>
|
||||||
tuttiMatch.length === 0 ? (
|
tuttiMatch.length === 0 ? (
|
||||||
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
||||||
Nessuna partita disponibile.
|
Nessuna partita disponibile.
|
||||||
@@ -158,6 +172,8 @@ function Classifica() {
|
|||||||
const contenuto = (
|
const contenuto = (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<LogoSquadra src={m.logoAvversario} alt={m.avversario} className="h-8 w-8" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-bold">
|
<p className="truncate text-sm font-bold">
|
||||||
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
{m.casa ? "CRAP Volley" : m.avversario} vs{" "}
|
||||||
@@ -168,6 +184,7 @@ function Classifica() {
|
|||||||
{m.scout ? " · scoutata" : ""}
|
{m.scout ? " · scoutata" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl px-2.5 py-1 font-display text-lg",
|
"rounded-xl px-2.5 py-1 font-display text-lg",
|
||||||
@@ -199,7 +216,8 @@ function Classifica() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return eventoId ? (
|
if (eventoId) {
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={m.id}
|
key={m.id}
|
||||||
to="/partita/$id"
|
to="/partita/$id"
|
||||||
@@ -208,15 +226,45 @@ function Classifica() {
|
|||||||
>
|
>
|
||||||
{contenuto}
|
{contenuto}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
// Senza evento CrAPP collegato (nessuna creazione automatica dal calendario
|
||||||
|
// CSI, vedi "Evoluzioni possibili"): le gare scoutate localmente non hanno un
|
||||||
|
// corrispettivo sul portale da mostrare, quelle CSI sì.
|
||||||
|
return m.scout ? (
|
||||||
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
<article key={m.id} className="rounded-3xl bg-card p-4 shadow-card">
|
||||||
{contenuto}
|
{contenuto}
|
||||||
</article>
|
</article>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={m.id}
|
||||||
|
to="/partita-csi/$id"
|
||||||
|
params={{ id: m.id }}
|
||||||
|
className="premi block rounded-3xl bg-card p-4 shadow-card active:scale-[0.99]"
|
||||||
|
>
|
||||||
|
{contenuto}
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
[tuttiMatch, eventoIdPerData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
titolo="Campionato"
|
||||||
|
sottotitolo={csi ? `${csi.girone} · CSI Bologna` : "CSI Bologna"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarraSottosezioni
|
||||||
|
defaultId={tab ?? "classifica"}
|
||||||
|
variante="sottolineatura"
|
||||||
|
riempiLarghezza
|
||||||
|
voci={[
|
||||||
|
{ id: "classifica", label: "Classifica", contenuto: contenutoClassifica },
|
||||||
|
{ id: "storico", label: "Storico partite", contenuto: contenutoStorico },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
type CategoriaEvento,
|
type CategoriaEvento,
|
||||||
type Evento,
|
type Evento,
|
||||||
} from "@/lib/eventi";
|
} from "@/lib/eventi";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
|
||||||
export const Route = createFileRoute("/eventi")({
|
export const Route = createFileRoute("/eventi")({
|
||||||
@@ -57,7 +57,8 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function GestioneEventi() {
|
function GestioneEventi() {
|
||||||
const io = useGiocatoreCorrente();
|
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useCsi } from "@/lib/csi";
|
|||||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||||
import { useScoutMatches } from "@/lib/scout-store";
|
import { useScoutMatches } from "@/lib/scout-store";
|
||||||
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
||||||
|
import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
@@ -238,8 +239,8 @@ function Index() {
|
|||||||
|
|
||||||
<Section titolo="Colpo d'occhio" indice={6}>
|
<Section titolo="Colpo d'occhio" indice={6}>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<StatTile valore={giocatore.presenze} label="Presenze" hint="+2 questo mese" />
|
<StatTile valore={giocatore.presenze} label="Presenze" />
|
||||||
<StatTile valore={giocatore.mediaVoto || "—"} label="Media voto" />
|
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
|
||||||
<StatTile valore={giocatore.mvp} label="MVP" />
|
<StatTile valore={giocatore.mvp} label="MVP" />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Trophy, Swords } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { useCsi } from "@/lib/csi";
|
||||||
|
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/partita-csi/$id")({
|
||||||
|
head: () => {
|
||||||
|
const titolo = "Dettaglio partita";
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: `${titolo} — CrAPP` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Dettaglio partita ufficiale CSI del CRAP Volley.",
|
||||||
|
},
|
||||||
|
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||||
|
{ property: "og:description", content: "Dettaglio partita ufficiale CSI del CRAP Volley." },
|
||||||
|
{ property: "og:type", content: "website" },
|
||||||
|
{ name: "twitter:card", content: "summary" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: PartitaCsiDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dettaglio "solo CSI" per le gare senza un evento CrAPP collegato (l'app non crea ancora
|
||||||
|
* eventi automaticamente dal calendario CSI): niente convocazioni/presenze/MVP/scout, solo
|
||||||
|
* i dati ufficiali del portale. Per le gare già collegate a un evento vedi `/partita/$id`,
|
||||||
|
* che mostra le stesse informazioni CSI in più.
|
||||||
|
*/
|
||||||
|
function PartitaCsiDetail() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { data: csi } = useCsi();
|
||||||
|
const partita = csi?.partite.find((p) => p.id === id);
|
||||||
|
|
||||||
|
if (!partita) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 pt-8">
|
||||||
|
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const giocata = partita.setNostri !== null && partita.setLoro !== null;
|
||||||
|
const vinta = giocata && (partita.setNostri as number) > (partita.setLoro as number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader titolo="Partita" sottotitolo={formatData(partita.data)} />
|
||||||
|
|
||||||
|
<Section titolo={partita.competizione || "Campionato"}>
|
||||||
|
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid h-12 w-12 shrink-0 place-items-center rounded-2xl",
|
||||||
|
partita.casa ? "bg-primary/15 text-primary" : "bg-accent/15 text-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{partita.casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
|
||||||
|
</div>
|
||||||
|
<LogoSquadra
|
||||||
|
src={partita.logoAvversario}
|
||||||
|
alt={partita.avversario}
|
||||||
|
className="h-10 w-10"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{partita.casa ? "In casa" : "Fuori casa"}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</p>
|
||||||
|
</div>
|
||||||
|
{giocata ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-full px-3 py-1 font-display text-lg",
|
||||||
|
vinta
|
||||||
|
? "bg-success text-success-foreground"
|
||||||
|
: "bg-destructive text-destructive-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{partita.setNostri} - {partita.setLoro}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{giocata && partita.parziali.length > 0 ? (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||||
|
{partita.parziali.map(([noi, loro], i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||||
|
>
|
||||||
|
{noi}-{loro}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MetaPartitaCsi
|
||||||
|
girone={partita.girone}
|
||||||
|
numeroGara={partita.numeroGara}
|
||||||
|
arbitro={partita.arbitro}
|
||||||
|
link={partita.link}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section titolo="Formazioni e scontri diretti">
|
||||||
|
<DettaglioCsiEsteso matchId={partita.id} avversario={partita.avversario} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,12 @@ import { convocatiEvento, useEvento } from "@/lib/eventi";
|
|||||||
import { useRosa } from "@/lib/rosa";
|
import { useRosa } from "@/lib/rosa";
|
||||||
import { useCsi } from "@/lib/csi";
|
import { useCsi } from "@/lib/csi";
|
||||||
import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||||
|
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
|
||||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||||
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
||||||
@@ -46,7 +46,6 @@ export const Route = createFileRoute("/partita/$id")({
|
|||||||
function PartitaDetail() {
|
function PartitaDetail() {
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
const { evento } = useEvento(id);
|
const { evento } = useEvento(id);
|
||||||
const io = useGiocatoreCorrente();
|
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const scoutMatches = useScoutMatches();
|
const scoutMatches = useScoutMatches();
|
||||||
const { data: csi } = useCsi();
|
const { data: csi } = useCsi();
|
||||||
@@ -74,10 +73,16 @@ function PartitaDetail() {
|
|||||||
id: scout.id,
|
id: scout.id,
|
||||||
data: scout.data,
|
data: scout.data,
|
||||||
avversario: scout.avversario,
|
avversario: scout.avversario,
|
||||||
|
logoAvversario: "",
|
||||||
casa: scout.casa,
|
casa: scout.casa,
|
||||||
setNostri: scout.setNostri,
|
setNostri: scout.setNostri,
|
||||||
setLoro: scout.setLoro,
|
setLoro: scout.setLoro,
|
||||||
parziali: scout.parziali,
|
parziali: scout.parziali,
|
||||||
|
campo: "",
|
||||||
|
girone: "",
|
||||||
|
numeroGara: "",
|
||||||
|
arbitro: "",
|
||||||
|
link: "",
|
||||||
}
|
}
|
||||||
: csiMatch
|
: csiMatch
|
||||||
? matchDaPartitaCsi(csiMatch)
|
? matchDaPartitaCsi(csiMatch)
|
||||||
@@ -107,6 +112,9 @@ function PartitaDetail() {
|
|||||||
>
|
>
|
||||||
{casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
|
{casa ? <Swords className="h-6 w-6" /> : <Trophy className="h-6 w-6" />}
|
||||||
</div>
|
</div>
|
||||||
|
{match?.logoAvversario ? (
|
||||||
|
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
|
||||||
|
) : null}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{casa ? "In casa" : "Fuori casa"}
|
{casa ? "In casa" : "Fuori casa"}
|
||||||
@@ -136,6 +144,15 @@ function PartitaDetail() {
|
|||||||
Conferme: {presentiVeri}/{convocati.length}
|
Conferme: {presentiVeri}/{convocati.length}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{csiMatch ? (
|
||||||
|
<MetaPartitaCsi
|
||||||
|
girone={csiMatch.girone}
|
||||||
|
numeroGara={csiMatch.numeroGara}
|
||||||
|
arbitro={csiMatch.arbitro}
|
||||||
|
link={csiMatch.link}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TurnoPalloni eventoId={evento.id} />
|
<TurnoPalloni eventoId={evento.id} />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
@@ -173,6 +190,12 @@ function PartitaDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{csiMatch ? (
|
||||||
|
<Section titolo="Formazioni e scontri diretti">
|
||||||
|
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Section titolo="MVP eletto dalla squadra">
|
<Section titolo="MVP eletto dalla squadra">
|
||||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||||
<VotazioneMvp evento={evento} />
|
<VotazioneMvp evento={evento} />
|
||||||
|
|||||||
@@ -130,11 +130,15 @@ function Profilo() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Qui il link al profilo sarebbe un link a sé stessa: torna il logo. */}
|
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
titolo={g.nome}
|
titolo={g.nome}
|
||||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||||
azione={<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />}
|
azione={
|
||||||
|
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||||
|
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Reveal className="-mt-6 px-5">
|
<Reveal className="-mt-6 px-5">
|
||||||
@@ -184,10 +188,11 @@ function Profilo() {
|
|||||||
<BarraSottosezioni
|
<BarraSottosezioni
|
||||||
key={tabIniziale}
|
key={tabIniziale}
|
||||||
defaultId={tabIniziale}
|
defaultId={tabIniziale}
|
||||||
|
riempiLarghezza
|
||||||
voci={[
|
voci={[
|
||||||
{
|
{
|
||||||
id: "stagione",
|
id: "stagione",
|
||||||
label: "Stagione",
|
label: "Season",
|
||||||
contenuto: (
|
contenuto: (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -225,7 +230,7 @@ function Profilo() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "documenti",
|
id: "documenti",
|
||||||
label: "Documenti",
|
label: "Docs",
|
||||||
contenuto: <ProfiloAmministrativo giocatoreId={g.id} conTendina={false} />,
|
contenuto: <ProfiloAmministrativo giocatoreId={g.id} conTendina={false} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Card } from "@/components/crapp/ui-bits";
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
import type { Evento } from "@/lib/eventi";
|
import type { Evento } from "@/lib/eventi";
|
||||||
import { useRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
import { usePresenzeEvento } from "@/lib/presenze";
|
import { usePresenzeEvento } from "@/lib/presenze";
|
||||||
import {
|
import {
|
||||||
statoIniziale,
|
statoIniziale,
|
||||||
@@ -81,7 +81,8 @@ function Blocco({
|
|||||||
|
|
||||||
function Scout() {
|
function Scout() {
|
||||||
const { pronto, partita } = usePartitaDiOggi();
|
const { pronto, partita } = usePartitaDiOggi();
|
||||||
const io = useGiocatoreCorrente();
|
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase` basta, niente statistiche.
|
||||||
|
const io = useGiocatoreBase();
|
||||||
const sessione = useSessioneScout(partita?.id ?? null);
|
const sessione = useSessioneScout(partita?.id ?? null);
|
||||||
const statoSalvato = useStatoScout(partita?.id ?? null);
|
const statoSalvato = useStatoScout(partita?.id ?? null);
|
||||||
const apri = useApriSessioneScout();
|
const apri = useApriSessioneScout();
|
||||||
@@ -204,7 +205,8 @@ function ScoutBoard({
|
|||||||
const cancella = useCancellaStatoScout();
|
const cancella = useCancellaStatoScout();
|
||||||
const salvaMatch = useSalvaScoutMatch();
|
const salvaMatch = useSalvaScoutMatch();
|
||||||
const { risposte } = usePresenzeEvento(partita.id);
|
const { risposte } = usePresenzeEvento(partita.id);
|
||||||
const rosa = useRosa();
|
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
|
||||||
|
const rosa = useAnagraficaRosa();
|
||||||
const finito = useRef(false);
|
const finito = useRef(false);
|
||||||
|
|
||||||
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
|
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
|
||||||
|
|||||||
+73
-100
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
|
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -7,7 +7,13 @@ import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
|||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||||
import { useRosa, useObiettivi } from "@/lib/rosa";
|
import {
|
||||||
|
useRosa,
|
||||||
|
useObiettivi,
|
||||||
|
classificaRank,
|
||||||
|
dettaglioClassifica,
|
||||||
|
type CriterioClassifica,
|
||||||
|
} from "@/lib/rosa";
|
||||||
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
|
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
|
||||||
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
||||||
import { useCsi } from "@/lib/csi";
|
import { useCsi } from "@/lib/csi";
|
||||||
@@ -70,7 +76,7 @@ const criteri = [
|
|||||||
{ id: "cacchePartita", label: "Cacche" },
|
{ id: "cacchePartita", label: "Cacche" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type Criterio = (typeof criteri)[number]["id"];
|
type Criterio = CriterioClassifica;
|
||||||
|
|
||||||
function valore(
|
function valore(
|
||||||
g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number },
|
g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number },
|
||||||
@@ -95,6 +101,7 @@ function Squadra() {
|
|||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
|
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
|
||||||
|
const rank = classificaRank(ordinati.map((g) => valore(g, criterio)));
|
||||||
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
|
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
|
||||||
const { data: csi } = useCsi();
|
const { data: csi } = useCsi();
|
||||||
const matchGiocati = csi ? partiteGiocate(csi.partite).length : scoutMatches.length;
|
const matchGiocati = csi ? partiteGiocate(csi.partite).length : scoutMatches.length;
|
||||||
@@ -103,25 +110,14 @@ function Squadra() {
|
|||||||
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
|
? Math.round(obiettivi.reduce((s, o) => s + progressoObiettivo(o), 0) / obiettivi.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
// Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
|
||||||
<>
|
// non deve ricalcolare badge e obiettivi delle altre sezioni.
|
||||||
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
|
const contenutoRosa = useMemo(
|
||||||
|
() => (
|
||||||
<BarraSottosezioni
|
|
||||||
defaultId="rosa"
|
|
||||||
variante="sottolineatura"
|
|
||||||
voci={[
|
|
||||||
{
|
|
||||||
id: "rosa",
|
|
||||||
label: "Rosa",
|
|
||||||
contenuto: (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rosa.map((g) => {
|
{rosa.map((g) => {
|
||||||
const stati = badgeGiocatore(g);
|
const stati = badgeGiocatore(g);
|
||||||
const sbloccati = [
|
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||||
...stati.filter((b) => b.grado !== null),
|
|
||||||
...badgeSegretiSbloccati(g),
|
|
||||||
];
|
|
||||||
const isOpen = aperto === g.id;
|
const isOpen = aperto === g.id;
|
||||||
return (
|
return (
|
||||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||||
@@ -137,9 +133,7 @@ function Squadra() {
|
|||||||
className="h-11 w-11 text-lg"
|
className="h-11 w-11 text-lg"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-bold leading-tight">
|
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||||
{g.nome}
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 flex items-center gap-2">
|
<span className="mt-1 flex items-center gap-2">
|
||||||
<RuoloBadge ruolo={g.ruolo} />
|
<RuoloBadge ruolo={g.ruolo} />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -152,10 +146,7 @@ function Squadra() {
|
|||||||
{sbloccati.map((b) => {
|
{sbloccati.map((b) => {
|
||||||
const Icon = b.def.icon;
|
const Icon = b.def.icon;
|
||||||
return (
|
return (
|
||||||
<span
|
<span key={b.def.id} title={`${b.def.nome} — ${gradoMeta[b.grado!].label}`}>
|
||||||
key={b.def.id}
|
|
||||||
title={`${b.def.nome} — ${gradoMeta[b.grado!].label}`}
|
|
||||||
>
|
|
||||||
<Icon
|
<Icon
|
||||||
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
|
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -204,8 +195,7 @@ function Squadra() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/
|
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||||
{collezioneBadge(g).totali}
|
|
||||||
</p>
|
</p>
|
||||||
{sbloccati.length === 0 ? (
|
{sbloccati.length === 0 ? (
|
||||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||||
@@ -218,25 +208,16 @@ function Squadra() {
|
|||||||
const meta = gradoMeta[b.grado!];
|
const meta = gradoMeta[b.grado!];
|
||||||
return (
|
return (
|
||||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||||
<div
|
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||||
className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}
|
|
||||||
>
|
|
||||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||||
<p className="mt-1 text-xs font-bold leading-tight">
|
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
|
||||||
{b.def.nome}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{b.valore} {b.def.unita}
|
{b.valore} {b.def.unita}
|
||||||
{b.prossimaSoglia
|
{b.prossimaSoglia
|
||||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
|
||||||
className={cn(
|
|
||||||
"mt-1.5 text-xs font-bold uppercase",
|
|
||||||
meta.text,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,11 +233,12 @@ function Squadra() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
[rosa, aperto, mese],
|
||||||
{
|
);
|
||||||
id: "stats",
|
|
||||||
label: "Statistiche",
|
const contenutoStats = useMemo(
|
||||||
contenuto: (
|
() => (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
|
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
|
||||||
<StatTile valore={matchGiocati} label="Match giocati" />
|
<StatTile valore={matchGiocati} label="Match giocati" />
|
||||||
@@ -265,23 +247,17 @@ function Squadra() {
|
|||||||
<StatTile valore={team.ace} label="Ace squadra" />
|
<StatTile valore={team.ace} label="Ace squadra" />
|
||||||
<StatTile valore={team.muri} label="Muri squadra" />
|
<StatTile valore={team.muri} label="Muri squadra" />
|
||||||
</div>
|
</div>
|
||||||
),
|
|
||||||
},
|
<div className="border-t border-border pt-4">
|
||||||
{
|
|
||||||
id: "classifica-giocatori",
|
|
||||||
label: "Classifica",
|
|
||||||
aTuttoLarghezza: true,
|
|
||||||
contenuto: (
|
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFiltroAperto(true)}
|
onClick={() => setFiltroAperto(true)}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
className="flex w-full min-w-0 items-center gap-2 border-b border-border bg-card px-5 py-2.5 text-left"
|
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
|
||||||
>
|
>
|
||||||
<Trophy className="h-4 w-4 shrink-0 text-warning" aria-hidden />
|
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
|
||||||
<span className="shrink-0 text-sm text-foreground/80">Classifica per</span>
|
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-right text-sm font-bold">
|
<span className="min-w-0 flex-1 truncate text-right text-[14px] font-bold">
|
||||||
{criteri.find((c) => c.id === criterio)?.label}
|
{criteri.find((c) => c.id === criterio)?.label}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -313,23 +289,23 @@ function Squadra() {
|
|||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<div className="space-y-2 px-5 pt-3">
|
<div className="mt-3 space-y-2">
|
||||||
{ordinati.map((g, i) => (
|
{ordinati.map((g, i) => (
|
||||||
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-secondary font-display text-base">
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-secondary font-display text-lg">
|
||||||
{i + 1}
|
{rank[i]}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-bold">
|
<p className="truncate text-sm font-bold">
|
||||||
{g.nome}
|
{g.nome}
|
||||||
{i === 0 ? (
|
{rank[i] === 1 ? (
|
||||||
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
#{g.numero} · {g.ruolo} · {g.streak} presenze consecutive
|
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,13 +321,14 @@ function Squadra() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
|
||||||
{
|
);
|
||||||
id: "obiettivi",
|
|
||||||
label: "Obiettivi",
|
const contenutoObiettivi = useMemo(
|
||||||
contenuto: (
|
() => (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||||
<div className="flex items-end justify-between gap-3">
|
<div className="flex items-end justify-between gap-3">
|
||||||
@@ -367,10 +344,7 @@ function Squadra() {
|
|||||||
{completati}/{obiettivi.length} completati
|
{completati}/{obiettivi.length} completati
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Barra
|
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
|
||||||
percentuale={mediaObiettivi}
|
|
||||||
trackClassName="mt-3 bg-primary-foreground/15"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{obiettivi.map((o, i) => {
|
{obiettivi.map((o, i) => {
|
||||||
@@ -382,11 +356,7 @@ function Squadra() {
|
|||||||
indice={i}
|
indice={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
||||||
fatto
|
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||||
? "ring-success/40"
|
|
||||||
: pct >= 90
|
|
||||||
? "ring-accent/40"
|
|
||||||
: "ring-transparent",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
@@ -411,9 +381,7 @@ function Squadra() {
|
|||||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs font-semibold text-accent">
|
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||||
{microcopyObiettivo(o)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
@@ -421,11 +389,11 @@ function Squadra() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
[obiettivi, mediaObiettivi, completati],
|
||||||
{
|
);
|
||||||
id: "badge",
|
|
||||||
label: "Badge",
|
const contenutoBadge = useMemo(
|
||||||
contenuto: (
|
() => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{badgeDefs.map((b) => {
|
{badgeDefs.map((b) => {
|
||||||
const Icon = b.icon;
|
const Icon = b.icon;
|
||||||
@@ -435,9 +403,7 @@ function Squadra() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-5 w-5 text-accent" />
|
<Icon className="h-5 w-5 text-accent" />
|
||||||
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
||||||
<p className="ml-auto text-xs text-muted-foreground">
|
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
|
||||||
{descrizioneSoglie(b)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||||
{gradiOrdine.map((grado) => {
|
{gradiOrdine.map((grado) => {
|
||||||
@@ -451,18 +417,10 @@ function Squadra() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={grado}
|
key={grado}
|
||||||
className={cn(
|
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
||||||
"rounded-2xl p-2 text-center ring-1",
|
|
||||||
meta.bg,
|
|
||||||
meta.ring,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<p className={cn("text-xs font-bold uppercase", meta.text)}>
|
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
|
||||||
{meta.label}
|
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
||||||
</p>
|
|
||||||
<p className="font-display text-lg leading-none">
|
|
||||||
{b.soglie[grado]}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{quanti}/{rosa.length}
|
{quanti}/{rosa.length}
|
||||||
</p>
|
</p>
|
||||||
@@ -476,7 +434,22 @@ function Squadra() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
[rosa],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
|
||||||
|
|
||||||
|
<BarraSottosezioni
|
||||||
|
defaultId="rosa"
|
||||||
|
variante="sottolineatura"
|
||||||
|
riempiLarghezza
|
||||||
|
voci={[
|
||||||
|
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
|
||||||
|
{ id: "stats", label: "Stats", contenuto: contenutoStats },
|
||||||
|
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||||
|
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+20
-58
@@ -392,55 +392,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Superfici traslucide: se l'utente chiede meno trasparenza diventano piene. */
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
|
||||||
.materiale {
|
|
||||||
background-color: var(--color-card);
|
|
||||||
backdrop-filter: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-contrast: more) {
|
|
||||||
.materiale {
|
|
||||||
background-color: var(--color-card);
|
|
||||||
backdrop-filter: none;
|
|
||||||
border-color: var(--color-foreground);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Chrome flottante in stile Apple: la barra è un materiale traslucido sotto
|
* Vetro della barra flottante.
|
||||||
* cui il contenuto scorre, con una sfumatura al bordo al posto della linea
|
|
||||||
* netta (scroll edge effect).
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
* Il materiale deve reggersi sul colore, non sul blur.
|
|
||||||
*
|
*
|
||||||
* Con un fondo poco denso (era 72%) l'aspetto dipendeva quasi tutto da
|
* Su una superficie larga quanto lo schermo un fondo all'85% di card serve a
|
||||||
* `backdrop-filter`, ed è lì che i due motori divergono: WebKit sfoca in modo
|
* tenere allineati WebKit e Blink, che sfocano in modo diverso (608f2ed); su
|
||||||
* molto più denso e "latteo" di Blink, e `saturate` amplifica lo scarto. Il
|
* una pillola piccola come questa lo scarto non si legge, quindi si scende al
|
||||||
* risultato era una barra che su Safari sembrava piena e su Chrome Android
|
* 62% e si lascia che sia il blur a fare il lavoro, condizione perché si
|
||||||
* trasparente, a parità di CSS. A 85% il colore fa il lavoro e il blur è la
|
* legga come vetro invece che come plastica bianca.
|
||||||
* rifinitura: resta la profondità, sparisce quasi tutta la differenza.
|
|
||||||
*
|
|
||||||
* `in srgb` e non `in oklab`: mescolare con `transparent` — che è nero con
|
|
||||||
* alpha 0 — dipende da come il motore premoltiplica, e in srgb è implementato
|
|
||||||
* in modo molto più uniforme.
|
|
||||||
*/
|
|
||||||
@utility materiale {
|
|
||||||
background-color: color-mix(in srgb, var(--color-card) 85%, transparent);
|
|
||||||
backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Vetro della barra flottante. È `materiale` spinto: si vede attraverso.
|
|
||||||
*
|
|
||||||
* `materiale` sta all'85% di card perché è una superficie larga quanto lo
|
|
||||||
* schermo, dove il divario di blur fra WebKit e Blink si nota (608f2ed). Qui
|
|
||||||
* la superficie è una pillola piccola: si può scendere al 62% e lasciare che
|
|
||||||
* sia il blur a fare il lavoro, che è la condizione perché si legga come vetro
|
|
||||||
* invece che come plastica bianca.
|
|
||||||
*
|
*
|
||||||
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
|
* Le tre ombre interne sono la luce sul bordo, non decorazione: filo chiaro in
|
||||||
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
|
* alto (la luce che entra), filo scuro in basso (lo spessore del vetro), alone
|
||||||
@@ -475,6 +434,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* `data-leggero` lo mette `BottomNav` quando `useMotoRidotto` rileva un device
|
||||||
|
* debole (RAM bassa) o `prefers-reduced-motion`: la catena di filtri SVG sopra
|
||||||
|
* (feTurbulence + feDisplacementMap, solo Blink/Android) è il costo maggiore
|
||||||
|
* della barra fissa ad ogni cambio di rotta, quindi qui si torna al semplice
|
||||||
|
* sfondo pieno. Specificità più alta di `.vetro` da sola: vince a prescindere
|
||||||
|
* dall'ordine nel file.
|
||||||
|
*/
|
||||||
|
.vetro[data-leggero] {
|
||||||
|
background-color: var(--color-card);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
|
/* Il vetro traslucido è un vezzo: se l'utente chiede meno trasparenza sparisce. */
|
||||||
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
|
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
|
||||||
.vetro {
|
.vetro {
|
||||||
@@ -490,17 +463,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Chrome su Android disattiva `backdrop-filter` quando l'accelerazione
|
|
||||||
* hardware non è disponibile: senza questo, su quei device la barra resta
|
|
||||||
* solo semitrasparente e il contenuto si legge attraverso.
|
|
||||||
*/
|
|
||||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
|
||||||
.materiale {
|
|
||||||
background-color: var(--color-card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Padding inferiore della chrome flottante. */
|
/** Padding inferiore della chrome flottante. */
|
||||||
@utility pad-sicura-fondo {
|
@utility pad-sicura-fondo {
|
||||||
padding-bottom: var(--pad-sicura-fondo);
|
padding-bottom: var(--pad-sicura-fondo);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- M13 — Le tabelle di voto controllano anche a database chi può votare chi.
|
||||||
|
--
|
||||||
|
-- Due gap segnalati in `docs/modules/badge.md` (audit del modulo Badge), entrambi finora
|
||||||
|
-- solo filtri applicativi, aggirabili da chi scrive direttamente su PostgREST:
|
||||||
|
--
|
||||||
|
-- 1. Niente impediva di votare (o essere votati) da/per un giocatore non convocato
|
||||||
|
-- all'evento — vale per pagelle_voti, mvp_voti, badge_social_voti.
|
||||||
|
-- 2. `eventi_app.pagelle_chiuse` nascondeva solo i bottoni in UI: un voto "fuori tempo"
|
||||||
|
-- restava tecnicamente possibile.
|
||||||
|
--
|
||||||
|
-- Le policy "Ognuno gestisce i propri voti ..." di M11 vengono estese con un controllo sui
|
||||||
|
-- convocati dell'evento: `convocati` vuoto significa "tutta la rosa" (stessa convenzione di
|
||||||
|
-- `convocatiEvento()` in eventi.ts), quindi il controllo si applica solo se la lista non è
|
||||||
|
-- vuota. Le policy admin restano invariate: un amministratore può correggere un voto anche
|
||||||
|
-- per un giocatore che non risultava convocato o dopo la chiusura delle pagelle.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.evento_permette_voto(
|
||||||
|
p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean
|
||||||
|
) RETURNS boolean
|
||||||
|
LANGUAGE sql STABLE AS $$
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM public.eventi_app e
|
||||||
|
WHERE e.id = p_match_id
|
||||||
|
AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati))
|
||||||
|
AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati))
|
||||||
|
AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- --- pagelle: anche convocazione e chiusura --------------------------------------------
|
||||||
|
DROP POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti;
|
||||||
|
|
||||||
|
CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- MVP: solo convocazione, la votazione non ha un flag di chiusura -------------------
|
||||||
|
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
|
||||||
|
|
||||||
|
CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- --- badge social: solo convocazione, stesso motivo -------------------------------------
|
||||||
|
DROP POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti;
|
||||||
|
|
||||||
|
CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti
|
||||||
|
FOR ALL TO authenticated
|
||||||
|
USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.giocatori_squadra g
|
||||||
|
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- M14 — Pulizia a cascata dei dati collegati quando un evento viene cancellato
|
||||||
|
--
|
||||||
|
-- Un audit del modulo Obiettivi ha verificato che gli obiettivi in sé non hanno bisogno di
|
||||||
|
-- nessuna pulizia: sono ricalcolati a runtime sull'elenco eventi corrente (`obiettivi.ts`), e
|
||||||
|
-- un evento cancellato semplicemente sparisce da quell'elenco. Il problema è un livello sotto:
|
||||||
|
-- `useEliminaEvento()` (`src/lib/eventi.ts`) cancella solo la riga in `eventi_app`, lasciando
|
||||||
|
-- orfane le righe collegate in `risposte_presenze`, `cacche_partita`, `mvp_voti`,
|
||||||
|
-- `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live` e
|
||||||
|
-- `scout_partite` — nessuna di queste ha mai avuto una foreign key verso `eventi_app(id)`.
|
||||||
|
--
|
||||||
|
-- Una FK con ON DELETE CASCADE non è applicabile oggi: `mvp.md` documenta che storicamente
|
||||||
|
-- `match_id` in `mvp_voti`/`pagelle_voti`/`badge_social_voti` a volte conteneva l'id di una
|
||||||
|
-- sessione Scout o di una partita CSI, non l'id evento CrAPP — un vincolo FK rifiuterebbe la
|
||||||
|
-- migration alla prima riga storica disallineata. Un trigger non valida i dati esistenti,
|
||||||
|
-- solo le cancellazioni da qui in avanti, quindi è applicabile senza bonificare prima lo
|
||||||
|
-- storico (bonifica che resta un lavoro separato, se mai servirà).
|
||||||
|
--
|
||||||
|
-- SECURITY DEFINER: le policy DELETE di alcune tabelle collegate potrebbero in futuro
|
||||||
|
-- restringersi (oggi sono tutte aperte, DD-023); il trigger deve continuare a pulire a
|
||||||
|
-- prescindere da chi ha eseguito la DELETE su eventi_app.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.pulisci_dati_evento_cancellato()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM public.risposte_presenze WHERE evento_id = OLD.id;
|
||||||
|
DELETE FROM public.cacche_partita WHERE evento_id = OLD.id;
|
||||||
|
DELETE FROM public.mvp_voti WHERE match_id = OLD.id;
|
||||||
|
DELETE FROM public.pagelle_voti WHERE match_id = OLD.id;
|
||||||
|
DELETE FROM public.badge_social_voti WHERE match_id = OLD.id;
|
||||||
|
DELETE FROM public.turni_palloni WHERE evento_id = OLD.id;
|
||||||
|
DELETE FROM public.scout_sessioni WHERE evento_id = OLD.id;
|
||||||
|
DELETE FROM public.scout_live WHERE evento_id = OLD.id;
|
||||||
|
DELETE FROM public.scout_partite WHERE evento_id = OLD.id;
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER eventi_app_pulisci_dati_collegati
|
||||||
|
AFTER DELETE ON public.eventi_app
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.pulisci_dati_evento_cancellato();
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- M15 — Bonifica delle righe orfane lasciate da eventi cancellati prima di M14
|
||||||
|
--
|
||||||
|
-- M14 ha aggiunto un trigger che pulisce a cascata i dati collegati quando un evento viene
|
||||||
|
-- cancellato (DD-029), ma agisce solo sulle cancellazioni da quel momento in avanti. Questa
|
||||||
|
-- migration ripulisce una tantum le righe orfane lasciate da cancellazioni PRECEDENTI a M14.
|
||||||
|
--
|
||||||
|
-- Per `risposte_presenze`, `cacche_partita`, `turni_palloni`, `scout_sessioni`, `scout_live` e
|
||||||
|
-- `scout_partite`, `evento_id` ha sempre e solo indicato un id evento CrAPP (mai un altro
|
||||||
|
-- schema): qualsiasi riga il cui `evento_id` non esiste più in `eventi_app` è, senza ambiguità,
|
||||||
|
-- un orfano da una cancellazione passata (per `scout_partite`, `evento_id` può anche essere
|
||||||
|
-- legittimamente NULL — una partita scoutata mai collegata a un evento — e quelle righe non
|
||||||
|
-- vengono toccate).
|
||||||
|
--
|
||||||
|
-- `mvp_voti`, `pagelle_voti` e `badge_social_voti` sono diverse: PRIMA che `match_id`
|
||||||
|
-- diventasse l'id evento CrAPP, contenevano l'id di una sessione Scout (formato `s` + timestamp
|
||||||
|
-- in base 10, es. "s1717426810123") o la chiave di un referto CSI (id numerico del portale, o
|
||||||
|
-- fallback "data-squadra-squadra"). Quei voti sono dati storici legittimi, mai stati collegati
|
||||||
|
-- a un evento CrAPP: docs/modules/mvp.md li descrive come "non più letti da nessuna schermata",
|
||||||
|
-- non come dati da eliminare. Cancellarli qui sarebbe un bug, non una bonifica.
|
||||||
|
--
|
||||||
|
-- Il filtro `match_id ~ '^e[0-9a-z]+$'` isola solo i match_id nel formato di
|
||||||
|
-- `nuovoIdEvento()` (`"e" + Date.now().toString(36)`, src/lib/eventi.ts): nessun id Scout (che
|
||||||
|
-- inizia per "s") o CSI (numerico o con trattini) può rientrarci, quindi solo i veri orfani da
|
||||||
|
-- evento CrAPP cancellato vengono rimossi, mai un voto storico su id scout/CSI.
|
||||||
|
|
||||||
|
DELETE FROM public.risposte_presenze
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.cacche_partita
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.turni_palloni
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_sessioni
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_live
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_partite
|
||||||
|
WHERE evento_id IS NOT NULL
|
||||||
|
AND evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.mvp_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.pagelle_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.badge_social_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- M16 — La bonifica di M15 diventa una funzione richiamabile e testabile
|
||||||
|
--
|
||||||
|
-- M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima di M14
|
||||||
|
-- (DD-029), con un blocco di DELETE eseguito una sola volta: non restava nulla da richiamare
|
||||||
|
-- né da testare in automatico, solo una verifica manuale fatta prima di applicarla.
|
||||||
|
--
|
||||||
|
-- Qui lo stesso corpo diventa la funzione `bonifica_dati_evento_orfani()`: se in futuro il
|
||||||
|
-- trigger di M14 venisse per errore rimosso o disattivato, o emergesse un altro batch di
|
||||||
|
-- orfani per un motivo imprevisto, si può rilanciare `select bonifica_dati_evento_orfani();`
|
||||||
|
-- invece di riscrivere da capo la stessa query delicata — e la sua logica resta coperta da
|
||||||
|
-- un test di integrazione (`test/integration/bonifica-evento.test.ts`) invece che verificata
|
||||||
|
-- a mano una tantum.
|
||||||
|
--
|
||||||
|
-- Riservata al service role: non è un'azione che un giocatore o un admin devono poter
|
||||||
|
-- richiamare dall'app, solo un intervento di manutenzione database.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.bonifica_dati_evento_orfani()
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM public.risposte_presenze
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.cacche_partita
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.turni_palloni
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_sessioni
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_live
|
||||||
|
WHERE evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.scout_partite
|
||||||
|
WHERE evento_id IS NOT NULL
|
||||||
|
AND evento_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
-- Solo i match_id nel formato di nuovoIdEvento() ("e" + timestamp base36): i vecchi voti
|
||||||
|
-- storici su id Scout ("s" + timestamp) o CSI (numerico o "data-squadra-squadra") non
|
||||||
|
-- rientrano nel filtro e restano intatti (vedi M15 per il dettaglio).
|
||||||
|
DELETE FROM public.mvp_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.pagelle_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
|
||||||
|
DELETE FROM public.badge_social_voti
|
||||||
|
WHERE match_id ~ '^e[0-9a-z]+$'
|
||||||
|
AND match_id NOT IN (SELECT id FROM public.eventi_app);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
REVOKE ALL ON FUNCTION public.bonifica_dati_evento_orfani() FROM PUBLIC;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.bonifica_dati_evento_orfani() TO service_role;
|
||||||
+9
-1
@@ -26,7 +26,7 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
|||||||
| Cartella | Cosa verifica | Serve rete? |
|
| Cartella | Cosa verifica | Serve rete? |
|
||||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No |
|
||||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`) e le letture lato server delle route push (`lettori-server`) sul database locale | Sì |
|
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) e la funzione di bonifica delle righe orfane (`bonifica-evento`, M16) sul database locale | Sì |
|
||||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle
|
|||||||
bun test/integration/permessi-route.test.ts # chi può far partire le notifiche
|
bun test/integration/permessi-route.test.ts # chi può far partire le notifiche
|
||||||
bun test/integration/scritture.test.ts # semantica degli upsert e vincoli
|
bun test/integration/scritture.test.ts # semantica degli upsert e vincoli
|
||||||
bun test/integration/lettori-server.test.ts # le letture server delle route push
|
bun test/integration/lettori-server.test.ts # le letture server delle route push
|
||||||
|
bun test/integration/pulizia-evento.test.ts # cascata alla cancellazione di un evento (M14)
|
||||||
|
bun test/integration/bonifica-evento.test.ts # funzione di bonifica righe orfane (M16)
|
||||||
```
|
```
|
||||||
|
|
||||||
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
|
`permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al
|
||||||
@@ -102,6 +104,12 @@ Due cose scoperte scrivendo questi test, utili a chi ne aggiunge:
|
|||||||
I test che richiedono `SUPABASE_SERVICE_ROLE_KEY` si saltano da soli se manca.
|
I test che richiedono `SUPABASE_SERVICE_ROLE_KEY` si saltano da soli se manca.
|
||||||
- Il check del parsing CSI può girare contro il portale reale:
|
- Il check del parsing CSI può girare contro il portale reale:
|
||||||
`CSI_LIVE=1 bun test/unit/csi-core.test.ts`.
|
`CSI_LIVE=1 bun test/unit/csi-core.test.ts`.
|
||||||
|
- I 5 test di `integration/api.test.ts` che leggono il CSI reale (classifica, partite,
|
||||||
|
cache, obiettivi o3/o4/o5) sondano `/api/public/csi` prima di partire: se il portale non
|
||||||
|
risponde (in manutenzione lato loro, vedi
|
||||||
|
[docs/modules/collegamento-csi.md](../docs/modules/collegamento-csi.md) § Limiti noti) si
|
||||||
|
saltano da soli invece di far fallire la suite, e tornano a girare da soli quando il
|
||||||
|
portale risponde di nuovo.
|
||||||
|
|
||||||
## Limite noto
|
## Limite noto
|
||||||
|
|
||||||
|
|||||||
@@ -70,11 +70,25 @@ try {
|
|||||||
const html = await pagina("/classifica");
|
const html = await pagina("/classifica");
|
||||||
assert.match(titolo(html), /Classifica campionato/);
|
assert.match(titolo(html), /Classifica campionato/);
|
||||||
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
|
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
|
||||||
|
|
||||||
|
// Una gara giocata deve avere un dettaglio "solo CSI" raggiungibile, con il suo
|
||||||
|
// endpoint per-partita servito (formazioni/precedenti): vedi partita-csi.$id.tsx.
|
||||||
|
const giocata = csi.partite.find((p) => p.setNostri !== null);
|
||||||
|
if (giocata) {
|
||||||
|
const htmlPartita = await pagina(`/partita-csi/${giocata.id}`);
|
||||||
|
assert.match(titolo(htmlPartita), /Dettaglio partita/);
|
||||||
|
const dettaglio = await fetch(url(`/api/public/csi-partita/${giocata.id}`));
|
||||||
|
assert.equal(dettaglio.status, 200, "l'endpoint per-partita risponde");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 4. Dettaglio di un evento --------------------------------------------
|
// --- 4. Dettaglio di un evento --------------------------------------------
|
||||||
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
|
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
|
||||||
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
|
for (const percorso of [
|
||||||
|
"/partita/non-esiste",
|
||||||
|
"/allenamento/non-esiste",
|
||||||
|
"/partita-csi/non-esiste",
|
||||||
|
]) {
|
||||||
const res = await fetch(url(percorso));
|
const res = await fetch(url(percorso));
|
||||||
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
|
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,14 @@
|
|||||||
* Nessun test scrive sul database: solo letture e validazioni.
|
* Nessun test scrive sul database: solo letture e validazioni.
|
||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
|
import { giocatori } from "@/lib/crapp-data";
|
||||||
|
import {
|
||||||
|
isNostraSquadra,
|
||||||
|
partiteGiocate,
|
||||||
|
type DatiCsi,
|
||||||
|
type DettaglioPartitaCsi,
|
||||||
|
} from "@/lib/csi-core";
|
||||||
|
import { obiettiviSquadra } from "@/lib/obiettivi";
|
||||||
import { avviaServer, haSupabase, json } from "../helpers/server";
|
import { avviaServer, haSupabase, json } from "../helpers/server";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
@@ -23,18 +30,38 @@ try {
|
|||||||
// --- GET /api/public/csi ---------------------------------------------------
|
// --- GET /api/public/csi ---------------------------------------------------
|
||||||
let csi: DatiCsi | undefined;
|
let csi: DatiCsi | undefined;
|
||||||
|
|
||||||
await prova("GET /api/public/csi restituisce classifica e partite", async () => {
|
// Il portale CSI (livescore.csibologna.it) è a volte in manutenzione lato loro (visto un
|
||||||
|
// errore SQL in chiaro al posto del JSON, non un problema nostro): sonda una volta sola
|
||||||
|
// prima di questi 5 test, così un "non raggiungibile" temporaneo si vede come saltato,
|
||||||
|
// non come fallito — la suite resta verde senza nascondere il motivo, e senza cancellare
|
||||||
|
// la copertura per quando il portale torna su.
|
||||||
|
const sondaCsi = await fetch(url("/api/public/csi"));
|
||||||
|
const csiRaggiungibile = sondaCsi.ok;
|
||||||
|
if (!csiRaggiungibile) {
|
||||||
|
console.log(
|
||||||
|
` · CSI non raggiungibile (${sondaCsi.status}): portale probabilmente in manutenzione`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const provaCsi = (nome: string, fn: () => Promise<void> | void) =>
|
||||||
|
csiRaggiungibile
|
||||||
|
? prova(nome, fn)
|
||||||
|
: Promise.resolve(
|
||||||
|
salta(nome, "portale CSI non raggiungibile (probabile manutenzione lato loro)"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await provaCsi("GET /api/public/csi restituisce classifica e partite", async () => {
|
||||||
const res = await fetch(url("/api/public/csi"));
|
const res = await fetch(url("/api/public/csi"));
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
|
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
|
||||||
csi = (await json(res)) as DatiCsi;
|
csi = (await json(res)) as DatiCsi;
|
||||||
assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0);
|
assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0);
|
||||||
|
assert.ok(Array.isArray(csi.classificaCoppa));
|
||||||
assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0);
|
assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0);
|
||||||
assert.equal(csi.girone, "Girone B");
|
assert.equal(csi.girone, "Girone B");
|
||||||
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
|
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
|
||||||
});
|
});
|
||||||
|
|
||||||
await prova("la classifica contiene la nostra squadra con dati coerenti", () => {
|
await provaCsi("la classifica contiene la nostra squadra con dati coerenti", () => {
|
||||||
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||||
assert.ok(noi, "C.R.A.P. Volley presente in classifica");
|
assert.ok(noi, "C.R.A.P. Volley presente in classifica");
|
||||||
assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate");
|
assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate");
|
||||||
@@ -47,7 +74,13 @@ try {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await prova("ogni partita è coerente con il formato dell'app", () => {
|
await provaCsi("la classifica di Coppa, se presente, contiene la nostra squadra", () => {
|
||||||
|
if ((csi?.classificaCoppa.length ?? 0) === 0) return; // fase a gironi finita, pagina cambiata
|
||||||
|
const noi = csi?.classificaCoppa.find((r) => isNostraSquadra(r.squadra));
|
||||||
|
assert.ok(noi, "C.R.A.P. Volley presente nel girone di Coppa");
|
||||||
|
});
|
||||||
|
|
||||||
|
await provaCsi("ogni partita è coerente con il formato dell'app", () => {
|
||||||
for (const p of csi?.partite ?? []) {
|
for (const p of csi?.partite ?? []) {
|
||||||
assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`);
|
assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`);
|
||||||
assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`);
|
assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`);
|
||||||
@@ -58,13 +91,78 @@ try {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await prova("la seconda chiamata arriva dalla cache del server", async () => {
|
await provaCsi(
|
||||||
|
"le vittorie in campionato lette dal CSI reale alimentano o3/o4/o5 in modo coerente",
|
||||||
|
() => {
|
||||||
|
// Stessa logica di useObiettivi() in src/lib/rosa.ts: partite giocate e vinte.
|
||||||
|
const vittorieReali = partiteGiocate(csi?.partite ?? []).filter(
|
||||||
|
(p) => (p.setNostri ?? 0) > (p.setLoro ?? 0),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const obiettivi = obiettiviSquadra(giocatori, {
|
||||||
|
eventi: [],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
vittorie: vittorieReali,
|
||||||
|
});
|
||||||
|
const o3 = obiettivi.find((o) => o.id === "o3")!;
|
||||||
|
const o4 = obiettivi.find((o) => o.id === "o4")!;
|
||||||
|
const o5 = obiettivi.find((o) => o.id === "o5")!;
|
||||||
|
|
||||||
|
assert.deepEqual([o3.target, o4.target, o5.target], [1, 5, 10], "target fissi 1/5/10");
|
||||||
|
assert.equal(o3.valore, Math.min(vittorieReali, 1), "prima vittoria, cappata a 1");
|
||||||
|
assert.equal(o4.valore, Math.min(vittorieReali, 5), "5 vittorie, cappata a 5");
|
||||||
|
assert.equal(o5.valore, Math.min(vittorieReali, 10), "10 vittorie, cappata a 10");
|
||||||
|
assert.ok(o3.valore <= o4.valore && o4.valore <= o5.valore, "progressione o3 ≤ o4 ≤ o5");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await provaCsi("la seconda chiamata arriva dalla cache del server", async () => {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi;
|
const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi;
|
||||||
assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch");
|
assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch");
|
||||||
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
|
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await provaCsi(
|
||||||
|
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
|
||||||
|
async () => {
|
||||||
|
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
|
||||||
|
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
|
||||||
|
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const dettaglio = (await json(res)) as DettaglioPartitaCsi;
|
||||||
|
// formazioni/precedenti possono essere null se il portale non li pubblica per questa
|
||||||
|
// gara (es. referto non compilato): qui si verifica solo che, quando presenti, siano
|
||||||
|
// coerenti — non che ci siano sempre, vedi parseFormazioni()/parsePrecedenti().
|
||||||
|
if (dettaglio.formazioni) {
|
||||||
|
assert.ok(isNostraSquadra(dettaglio.formazioni.noi.squadra));
|
||||||
|
assert.ok(dettaglio.formazioni.noi.titolari.length > 0, "almeno un titolare");
|
||||||
|
}
|
||||||
|
if (dettaglio.precedenti) {
|
||||||
|
assert.ok(dettaglio.precedenti.totale >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||||
|
assert.equal(secondo.status, 200);
|
||||||
|
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await provaCsi(
|
||||||
|
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
|
||||||
|
async () => {
|
||||||
|
// Un match_id inventato fa rispondere 500 a match-stats.php lato CSI (verificato a
|
||||||
|
// mano con curl): non è "gara senza formazioni ancora pubblicate" (quello risponde
|
||||||
|
// 200 con markup vuoto, gestito da parseFormazioni()/parsePrecedenti() → null), è un
|
||||||
|
// vero errore a monte. La route lo tratta come /api/public/csi tratta un CSI giù:
|
||||||
|
// 503 pulito, non un crash o un 200 con dati inventati.
|
||||||
|
const res = await fetch(url("/api/public/csi-partita/999999999"));
|
||||||
|
assert.equal(res.status, 503);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// --- GET /api/public/push-config -------------------------------------------
|
// --- GET /api/public/push-config -------------------------------------------
|
||||||
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
|
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
|
||||||
const res = await fetch(url("/api/public/push-config"));
|
const res = await fetch(url("/api/public/push-config"));
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Badge social (5 categorie) end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/badge-social.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/badge-social.test.ts`) verificano `conteggioCategoria()`,
|
||||||
|
* `vincitoreCategoria()` e `badgeSocialVinti()` come funzioni pure, con categorie inventate
|
||||||
|
* ("sorriso", "urlo" in `scritture.test.ts`) o con solo 2-3 delle 5 reali. Qui invece si
|
||||||
|
* scrivono voti veri su `badge_social_voti` con le 5 categorie effettive di `categorieSocial`
|
||||||
|
* (`affidabile`, `spirito`, `fairplay`, `meme`, `cuore`), si rileggono via REST con la stessa
|
||||||
|
* selezione di `useVotiSocial()`, e si passa il risultato attraverso `badgeSocialVinti()`: se
|
||||||
|
* una colonna cambia nome o un id di categoria diverge da quello scritto dall'app, qui si vede,
|
||||||
|
* perché non c'è nessun vincolo CHECK a database sulla colonna `categoria` (vedi
|
||||||
|
* `docs/modules/badge.md` § Problemi noti) — l'unica difesa è l'app che manda sempre uno dei 5
|
||||||
|
* id validi, e questo test lo dimostra con dati reali su tutte e 5, non solo su un paio.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-badge-social`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
badgeSocialVinti,
|
||||||
|
categorieSocial,
|
||||||
|
vincitoreCategoria,
|
||||||
|
type VotoSocial,
|
||||||
|
} from "@/lib/badge-social";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("badge social sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("badge-social");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge social su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-badge-social";
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function vota(voto: VotoSocial) {
|
||||||
|
const res = await rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify(voto),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su badge_social_voti: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiVoti(): Promise<VotoSocial[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`badge_social_voti?match_id=like.${PREFISSO}-*&select=match_id,categoria,votante_id,votato_id,votato_nome`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as VotoSocial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"tutte e 5 le categorie reali si contano e si vincono indipendentemente",
|
||||||
|
async () => {
|
||||||
|
// "bs1" vince nettamente tutte e 5 le categorie reali nella partita m1 (2 voti contro
|
||||||
|
// 1 ciascuna): dimostra che l'id di categoria non è solo una stringa di comodo nei
|
||||||
|
// test unitari, ma funziona identico per tutte e 5 quelle vere dell'app.
|
||||||
|
for (const cat of categorieSocial) {
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
categoria: cat.id,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "bs1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
categoria: cat.id,
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "bs1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
categoria: cat.id,
|
||||||
|
votante_id: "vc",
|
||||||
|
votato_id: "bs2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const voti = await leggiVoti();
|
||||||
|
assert.equal(voti.length, categorieSocial.length * 3, "tutti i voti scritti si rileggono");
|
||||||
|
|
||||||
|
for (const cat of categorieSocial) {
|
||||||
|
const vincitore = vincitoreCategoria(voti, `${PREFISSO}-m1`, cat.id);
|
||||||
|
assert.equal(vincitore?.id, "bs1", `bs1 vince "${cat.id}" con vantaggio netto`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinti = badgeSocialVinti(voti, "bs1");
|
||||||
|
assert.deepEqual(
|
||||||
|
vinti,
|
||||||
|
Object.fromEntries(categorieSocial.map((c) => [c.id, 1])),
|
||||||
|
"una vittoria per ciascuna delle 5 categorie reali, nessuna persa per strada",
|
||||||
|
);
|
||||||
|
assert.deepEqual(badgeSocialVinti(voti, "bs2"), {}, "bs2 non vince mai nettamente");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("una parità su una categoria reale non assegna il badge, le altre sì", async () => {
|
||||||
|
// Nella partita m2, "affidabile" finisce in parità (nessun vincitore), le altre 4 le
|
||||||
|
// vince ancora "bs1": la parità deve bloccare solo la categoria coinvolta.
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
categoria: "affidabile",
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "bs1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
categoria: "affidabile",
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "bs2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) {
|
||||||
|
await vota({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
categoria: cat.id,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "bs1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const voti = await leggiVoti();
|
||||||
|
assert.equal(
|
||||||
|
vincitoreCategoria(voti, `${PREFISSO}-m2`, "affidabile"),
|
||||||
|
null,
|
||||||
|
"1 voto contro 1: parità, nessun vincitore",
|
||||||
|
);
|
||||||
|
const vintiTotali = badgeSocialVinti(voti, "bs1");
|
||||||
|
// m1 (tutte e 5) + m2 (le 4 non in parità): "affidabile" resta a 1 (solo m1), le altre 4 a 2.
|
||||||
|
assert.equal(vintiTotali["affidabile"], 1, "la parità in m2 non aggiunge una vittoria");
|
||||||
|
for (const cat of categorieSocial.filter((c) => c.id !== "affidabile")) {
|
||||||
|
assert.equal(vintiTotali[cat.id], 2, `"${cat.id}" vinta sia in m1 sia in m2`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`badge_social_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("badge-social");
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Funzione `bonifica_dati_evento_orfani()` (M16): `bun test/integration/bonifica-evento.test.ts`.
|
||||||
|
*
|
||||||
|
* M15 ha ripulito una tantum le righe orfane lasciate da eventi cancellati prima del trigger
|
||||||
|
* di M14 (DD-029); M16 ha reso permanente la stessa logica come funzione RPC, così resta
|
||||||
|
* richiamabile e testabile invece che verificata a mano una volta sola.
|
||||||
|
*
|
||||||
|
* Copre tutte e nove le istruzioni dentro la funzione, non solo un sottoinsieme: le sei
|
||||||
|
* tabelle che usano `evento_id` (`risposte_presenze`, `cacche_partita`, `turni_palloni`,
|
||||||
|
* `scout_sessioni`, `scout_live`, `scout_partite`) e le tre che usano `match_id`
|
||||||
|
* (`mvp_voti`, `pagelle_voti`, `badge_social_voti`).
|
||||||
|
*
|
||||||
|
* Il punto delicato è sulle tre tabelle `match_id`: la funzione deve cancellare solo i
|
||||||
|
* `match_id` nel formato id evento CrAPP ("e" + timestamp base36) senza corrispondenza in
|
||||||
|
* `eventi_app` — mai i vecchi voti storici su id Scout ("s" + timestamp) o CSI (numerico),
|
||||||
|
* che sono dati legittimi mai collegati a un evento CrAPP (`docs/modules/mvp.md`). Questo
|
||||||
|
* test verifica la distinzione su tutte e tre, non solo su due delle tre.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
|
||||||
|
* `test-bonifica-evento`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("bonifica dati evento orfani", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("bonifica-evento");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`bonifica dati evento orfani su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-bonifica-evento";
|
||||||
|
// Deve rispettare ^e[0-9a-z]+$ (formato di nuovoIdEvento()): un id con trattini non
|
||||||
|
// verrebbe mai filtrato dalla funzione, quindi non testerebbe la regola che conta.
|
||||||
|
const ORFANO = `etestbonificaevento${Date.now().toString(36)}`;
|
||||||
|
const STORICO_SCOUT = `s${Date.now()}`; // formato id Scout storico: va preservato
|
||||||
|
const STORICO_CSI = `${Date.now()}`; // formato id CSI storico (numerico): va preservato
|
||||||
|
|
||||||
|
const TABELLE_EVENTO_ID = [
|
||||||
|
"risposte_presenze",
|
||||||
|
"cacche_partita",
|
||||||
|
"turni_palloni",
|
||||||
|
"scout_sessioni",
|
||||||
|
"scout_live",
|
||||||
|
"scout_partite",
|
||||||
|
];
|
||||||
|
const TABELLE_MATCH_ID = ["mvp_voti", "pagelle_voti", "badge_social_voti"];
|
||||||
|
|
||||||
|
const rest = (percorso: string, init?: RequestInit) =>
|
||||||
|
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
Prefer: "return=minimal",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function inserisci(tabella: string, riga: Record<string, unknown>) {
|
||||||
|
const res = await rest(tabella, { method: "POST", body: JSON.stringify(riga) });
|
||||||
|
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function esiste(tabella: string, filtro: string): Promise<boolean> {
|
||||||
|
const res = await rest(`${tabella}?${filtro}&select=*`, {
|
||||||
|
headers: { Prefer: "count=exact" },
|
||||||
|
});
|
||||||
|
return Number(res.headers.get("content-range")?.split("/")[1] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pulisci() {
|
||||||
|
for (const t of TABELLE_EVENTO_ID) {
|
||||||
|
await rest(`${t}?evento_id=eq.${ORFANO}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
await rest(`${t}?match_id=eq.${ORFANO}`, { method: "DELETE" });
|
||||||
|
await rest(`${t}?match_id=eq.${STORICO_SCOUT}`, { method: "DELETE" });
|
||||||
|
await rest(`${t}?match_id=eq.${STORICO_CSI}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"bonifica_dati_evento_orfani() rimuove solo gli orfani veri, non lo storico Scout/CSI",
|
||||||
|
async () => {
|
||||||
|
// Una riga orfana per ciascuna delle sei tabelle evento_id.
|
||||||
|
await inserisci("risposte_presenze", {
|
||||||
|
evento_id: ORFANO,
|
||||||
|
giocatore_id: `${PREFISSO}-g1`,
|
||||||
|
stato: "presente",
|
||||||
|
});
|
||||||
|
await inserisci("cacche_partita", {
|
||||||
|
evento_id: ORFANO,
|
||||||
|
giocatore_id: `${PREFISSO}-g1`,
|
||||||
|
quantita: 1,
|
||||||
|
});
|
||||||
|
await inserisci("turni_palloni", { evento_id: ORFANO, giocatore_id: `${PREFISSO}-g1` });
|
||||||
|
await inserisci("scout_sessioni", {
|
||||||
|
evento_id: ORFANO,
|
||||||
|
giocatore_id: `${PREFISSO}-g1`,
|
||||||
|
giocatore_nome: "Uno",
|
||||||
|
});
|
||||||
|
await inserisci("scout_live", { evento_id: ORFANO, stato: {} });
|
||||||
|
await inserisci("scout_partite", {
|
||||||
|
id: `${ORFANO}-scout`,
|
||||||
|
evento_id: ORFANO,
|
||||||
|
data: "2026-09-01",
|
||||||
|
avversario: "Test",
|
||||||
|
set_nostri: 3,
|
||||||
|
set_loro: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Una riga orfana + due storiche (Scout, CSI) per ciascuna delle tre tabelle match_id.
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
const base = { votante_id: `${PREFISSO}-va`, votato_id: `${PREFISSO}-vb` };
|
||||||
|
const extra =
|
||||||
|
t === "pagelle_voti"
|
||||||
|
? { voto: 7 }
|
||||||
|
: t === "badge_social_voti"
|
||||||
|
? { categoria: "top", votato_nome: "Test" }
|
||||||
|
: { votato_nome: "Test" };
|
||||||
|
await inserisci(t, { match_id: ORFANO, ...base, ...extra });
|
||||||
|
await inserisci(t, { match_id: STORICO_SCOUT, ...base, ...extra });
|
||||||
|
await inserisci(t, { match_id: STORICO_CSI, ...base, ...extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${URL_BASE}/rest/v1/rpc/bonifica_dati_evento_orfani`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`rpc bonifica_dati_evento_orfani: ${res.status} ${await res.text()}`);
|
||||||
|
|
||||||
|
for (const t of TABELLE_EVENTO_ID) {
|
||||||
|
assert.equal(await esiste(t, `evento_id=eq.${ORFANO}`), false, `${t}: orfano rimosso`);
|
||||||
|
}
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
assert.equal(await esiste(t, `match_id=eq.${ORFANO}`), false, `${t}: orfano rimosso`);
|
||||||
|
assert.equal(
|
||||||
|
await esiste(t, `match_id=eq.${STORICO_SCOUT}`),
|
||||||
|
true,
|
||||||
|
`${t}: storico Scout preservato`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await esiste(t, `match_id=eq.${STORICO_CSI}`),
|
||||||
|
true,
|
||||||
|
`${t}: storico CSI preservato`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await pulisci();
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("bonifica-evento");
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Badge MVP end-to-end contro il database locale: `bun test/integration/mvp-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `mvpVintiPerGiocatore()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui
|
||||||
|
* invece si scrivono voti veri su `mvp_voti`, si rileggono via REST con la stessa selezione di
|
||||||
|
* `useVotiMvp()`, e si passa il risultato attraverso `mvpVintiPerGiocatore()` fino a
|
||||||
|
* `statoBadge()` sul badge `mvp`: se una colonna cambia nome o la mappatura si rompe, qui il
|
||||||
|
* grado del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non
|
||||||
|
* toccano mai il database.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-mvp-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
|
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
|
||||||
|
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 MVP sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("mvp-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge MVP su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-mvp-badge";
|
||||||
|
const mvpDef = badgeDefs.find((b) => b.id === "mvp")!;
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upsert(riga: VotoMvp) {
|
||||||
|
const res = await rest("mvp_voti?on_conflict=match_id,votante_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify(riga),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come `useVotiMvp()`. */
|
||||||
|
async function leggiVoti(): Promise<VotoMvp[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as VotoMvp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||||
|
function giocatoreAzzerato(mvp: number): Giocatore {
|
||||||
|
return { ...giocatori[0]!, mvp, mediaVoto: 0, palloni: 0, presenze: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
|
||||||
|
async () => {
|
||||||
|
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
|
||||||
|
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
|
||||||
|
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "vc",
|
||||||
|
votato_id: "vg2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m3`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m3`,
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "vg2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
|
||||||
|
const voti = await leggiVoti();
|
||||||
|
const vinti = mvpVintiPerGiocatore(voti);
|
||||||
|
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
|
||||||
|
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
|
||||||
|
|
||||||
|
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
|
||||||
|
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
|
||||||
|
assert.equal(badgeVg1.prossimo, "argento");
|
||||||
|
assert.equal(badgeVg1.prossimaSoglia, 3, "manca 1 vittoria all'argento");
|
||||||
|
|
||||||
|
const badgeVg2 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg2"] ?? 0));
|
||||||
|
assert.equal(badgeVg2.grado, null, "zero vittorie nette: nessun badge sbloccato");
|
||||||
|
|
||||||
|
// Una terza vittoria netta porta vg1 da bronzo ad argento (soglia 3). Due voti,
|
||||||
|
// non uno solo: sotto il quorum minimo (VOTI_MINIMI_MVP) nessuna vittoria conterebbe.
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m4`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m4`,
|
||||||
|
votante_id: "vb",
|
||||||
|
votato_id: "vg1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
const vintiDopo = mvpVintiPerGiocatore(await leggiVoti());
|
||||||
|
assert.equal(vintiDopo["vg1"], 3);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(mvpDef, giocatoreAzzerato(vintiDopo["vg1"]!)).grado,
|
||||||
|
"argento",
|
||||||
|
"3 vittorie: soglia argento raggiunta",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("mvp-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
/**
|
||||||
|
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
|
||||||
|
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
|
||||||
|
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
|
||||||
|
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
|
||||||
|
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
||||||
|
* pura, con un `ContestoObiettivi` costruito a mano. Qui invece si scrivono righe vere su
|
||||||
|
* `eventi_app` e `risposte_presenze`, si rileggono con `leggiEventi()` (la stessa funzione che
|
||||||
|
* usa l'app lato server) e una query REST equivalente a `fetchPresenze()`, e si verifica che il
|
||||||
|
* risultato che arriva a `obiettiviSquadra()` sia quello atteso: se una colonna cambia nome o
|
||||||
|
* la mappatura si rompe, qui il valore torna sbagliato (o NaN) anche se i test unitari restano
|
||||||
|
* verdi, perché quelli non toccano mai il database.
|
||||||
|
*
|
||||||
|
* Il mese di test è marzo 2099: non collide con gli eventi del seed (tutti in agosto 2026), così
|
||||||
|
* questi due obiettivi — che ora aggregano su TUTTI gli eventi del mese corrente, non solo
|
||||||
|
* quelli del test — restano prevedibili senza dover filtrare gli eventi letti dal database.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-obiettivi`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { giocatori } from "@/lib/crapp-data";
|
||||||
|
import { obiettiviSquadra } from "@/lib/obiettivi";
|
||||||
|
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("obiettivi (o1) sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("obiettivi");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`obiettivi su ${URL_BASE}`);
|
||||||
|
|
||||||
|
// Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`.
|
||||||
|
process.env["SUPABASE_URL"] = URL_BASE;
|
||||||
|
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
|
||||||
|
|
||||||
|
const { leggiEventi } = await import("@/lib/eventi.server");
|
||||||
|
|
||||||
|
const PREFISSO = "test-obiettivi";
|
||||||
|
const MESE_TEST = "2099-03";
|
||||||
|
const OGGI = new Date("2099-03-15T10:00:00Z");
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stessa query di `fetchPresenze()` (`src/lib/presenze.ts`), raggruppata a mano. */
|
||||||
|
async function leggiPresenze(eventoId: string): Promise<MappaPresenze> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=eq.${eventoId}&select=evento_id,giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: string;
|
||||||
|
}>;
|
||||||
|
const mappa: MappaPresenze = {};
|
||||||
|
for (const r of righe) {
|
||||||
|
(mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato as never;
|
||||||
|
}
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventoId = `${PREFISSO}-e1`;
|
||||||
|
|
||||||
|
await prova("prepara evento e presenze reali su Supabase locale", async () => {
|
||||||
|
const inserito = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: eventoId,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Test obiettivi o1",
|
||||||
|
data: `${MESE_TEST}-10`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
|
||||||
|
// 9 presenti su 17 giocatori del roster reale (`crapp-data.ts`, id g1..g17).
|
||||||
|
const presenti = giocatori.slice(0, 9).map((g) => g.id);
|
||||||
|
const righe = giocatori.map((g) => ({
|
||||||
|
evento_id: eventoId,
|
||||||
|
giocatore_id: g.id,
|
||||||
|
stato: presenti.includes(g.id) ? "presente" : "assente",
|
||||||
|
}));
|
||||||
|
const inseritePresenze = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inseritePresenze.ok) {
|
||||||
|
throw new Error(`inserimento presenze fallito: ${await inseritePresenze.text()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"o1 legge l'evento e le presenze vere dal database e calcola la percentuale del mese",
|
||||||
|
async () => {
|
||||||
|
const eventiReali = await leggiEventi();
|
||||||
|
assert.ok(
|
||||||
|
eventiReali.some((e) => e.id === eventoId),
|
||||||
|
"leggiEventi() include l'evento appena inserito",
|
||||||
|
);
|
||||||
|
const presenzeReali = await leggiPresenze(eventoId);
|
||||||
|
|
||||||
|
const obiettivi = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
);
|
||||||
|
const o1 = obiettivi.find((o) => o.id === "o1")!;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
o1.valore,
|
||||||
|
Math.round((9 / giocatori.length) * 100),
|
||||||
|
"9 presenti su tutta la rosa, letti dal database",
|
||||||
|
);
|
||||||
|
assert.equal(o1.titolo, "90% di presenze a marzo", "titolo segue il mese iniettato");
|
||||||
|
assert.equal(o1.scadenza, "2099-03-31", "scadenza = ultimo giorno di marzo");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("un evento fuori dal mese di test non sposta o1", async () => {
|
||||||
|
const eventoFuoriMese = `${PREFISSO}-e2`;
|
||||||
|
const fuori = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: eventoFuoriMese,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Test obiettivi o1 - fuori mese",
|
||||||
|
data: "2099-04-01",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!fuori.ok) throw new Error(`inserimento evento fallito: ${await fuori.text()}`);
|
||||||
|
try {
|
||||||
|
const presenzeFuoriMese = giocatori.map((g) => ({
|
||||||
|
evento_id: eventoFuoriMese,
|
||||||
|
giocatore_id: g.id,
|
||||||
|
stato: "presente",
|
||||||
|
}));
|
||||||
|
const scritte = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(presenzeFuoriMese),
|
||||||
|
});
|
||||||
|
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = await leggiEventi();
|
||||||
|
const presenzeReali = {
|
||||||
|
...(await leggiPresenze(eventoId)),
|
||||||
|
...(await leggiPresenze(eventoFuoriMese)),
|
||||||
|
};
|
||||||
|
const o1 = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o1")!;
|
||||||
|
assert.equal(
|
||||||
|
o1.valore,
|
||||||
|
Math.round((9 / giocatori.length) * 100),
|
||||||
|
"l'evento di aprile non entra nel calcolo di marzo",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`eventi_app?id=eq.${eventoFuoriMese}`, { method: "DELETE" });
|
||||||
|
await rest(`risposte_presenze?evento_id=eq.${eventoFuoriMese}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"o1 aggrega su più eventi reali dello stesso mese, incluse le partite",
|
||||||
|
async () => {
|
||||||
|
// Isolati dal resto del database (come per o2/o7/o12): l'evento "e1" di un test
|
||||||
|
// precedente resta nello stesso mese e andrebbe a sporcare l'aggregazione se non
|
||||||
|
// filtrassimo sui soli eventi di questo blocco.
|
||||||
|
const allenamentoId = `${PREFISSO}-o1b-allenamento`;
|
||||||
|
const partitaId = `${PREFISSO}-o1b-partita`;
|
||||||
|
for (const [id, tipo, data] of [
|
||||||
|
[allenamentoId, "allenamento", `${MESE_TEST}-12`],
|
||||||
|
[partitaId, "partita", `${MESE_TEST}-19`],
|
||||||
|
] as const) {
|
||||||
|
const inserito = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o1 (${tipo})`, data }),
|
||||||
|
});
|
||||||
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tutta la rosa presente all'allenamento, nessuno alla partita: 50% aggregato sui due.
|
||||||
|
const risposte = [
|
||||||
|
...giocatori.map((g) => ({
|
||||||
|
evento_id: allenamentoId,
|
||||||
|
giocatore_id: g.id,
|
||||||
|
stato: "presente",
|
||||||
|
})),
|
||||||
|
...giocatori.map((g) => ({ evento_id: partitaId, giocatore_id: g.id, stato: "assente" })),
|
||||||
|
];
|
||||||
|
const scritte = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(risposte),
|
||||||
|
});
|
||||||
|
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
|
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||||
|
);
|
||||||
|
const presenzeReali = {
|
||||||
|
...(await leggiPresenze(allenamentoId)),
|
||||||
|
...(await leggiPresenze(partitaId)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const o1 = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o1")!;
|
||||||
|
assert.equal(
|
||||||
|
o1.valore,
|
||||||
|
50,
|
||||||
|
"le partite contano quanto gli allenamenti: 100% + 0% su due eventi reali = 50%",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("o2 aggrega le risposte su più eventi reali, non solo su uno", async () => {
|
||||||
|
const eventoA = `${PREFISSO}-o2b-a`;
|
||||||
|
const eventoB = `${PREFISSO}-o2b-b`;
|
||||||
|
for (const [id, data] of [
|
||||||
|
[eventoA, `${MESE_TEST}-02`],
|
||||||
|
[eventoB, `${MESE_TEST}-22`],
|
||||||
|
] as const) {
|
||||||
|
const inserito = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Test obiettivi o2 aggregato",
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tutti rispondono al primo evento, nessuno al secondo: 50% aggregato sui due.
|
||||||
|
const risposteA = giocatori.map((g) => ({
|
||||||
|
evento_id: eventoA,
|
||||||
|
giocatore_id: g.id,
|
||||||
|
stato: "presente",
|
||||||
|
}));
|
||||||
|
const scritte = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(risposteA),
|
||||||
|
});
|
||||||
|
if (!scritte.ok) throw new Error(`inserimento presenze fallito: ${await scritte.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = (await leggiEventi()).filter((e) => e.id === eventoA || e.id === eventoB);
|
||||||
|
const presenzeReali = await leggiPresenze(eventoA);
|
||||||
|
|
||||||
|
const o2 = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o2")!;
|
||||||
|
assert.equal(
|
||||||
|
o2.valore,
|
||||||
|
50,
|
||||||
|
"risposte piene su un evento, zero sull'altro = 50% aggregato sui due",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
||||||
|
async () => {
|
||||||
|
const pizzataId = `${PREFISSO}-pizzata`;
|
||||||
|
const inserita = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: pizzataId,
|
||||||
|
tipo: "evento",
|
||||||
|
titolo: "Test obiettivi o6",
|
||||||
|
data: `${MESE_TEST}-20`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = await leggiEventi();
|
||||||
|
const o6InMese = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o6")!;
|
||||||
|
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
|
||||||
|
|
||||||
|
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
|
||||||
|
const o6MeseDopo = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
|
OGGI_MESE_DOPO,
|
||||||
|
).find((o) => o.id === "o6")!;
|
||||||
|
assert.equal(
|
||||||
|
o6MeseDopo.valore,
|
||||||
|
0,
|
||||||
|
"lo stesso evento non conta più il mese successivo (si azzera)",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"o2 legge dal database risposte reali ed esclude i compleanni, con scadenza dinamica",
|
||||||
|
async () => {
|
||||||
|
// o2 (percentualeRisposte) aggrega su TUTTI gli eventi non-compleanno, senza filtro di
|
||||||
|
// mese: per un'asserzione deterministica isoliamo dal risultato reale solo i due eventi
|
||||||
|
// di questo test, invece di dipendere dal numero di eventi già presenti nel database
|
||||||
|
// (seed incluso).
|
||||||
|
const partitaId = `${PREFISSO}-o2-partita`;
|
||||||
|
const compleannoId = `${PREFISSO}-o2-compleanno`;
|
||||||
|
|
||||||
|
const inseritaPartita = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: partitaId,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: "Test obiettivi o2",
|
||||||
|
data: `${MESE_TEST}-05`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!inseritaPartita.ok) {
|
||||||
|
throw new Error(`inserimento evento fallito: ${await inseritaPartita.text()}`);
|
||||||
|
}
|
||||||
|
const inseritoCompleanno = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: compleannoId,
|
||||||
|
tipo: "compleanno",
|
||||||
|
titolo: "Test obiettivi o2 - compleanno",
|
||||||
|
data: `${MESE_TEST}-06`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!inseritoCompleanno.ok) {
|
||||||
|
throw new Error(`inserimento evento fallito: ${await inseritoCompleanno.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo 12 giocatori su tutta la rosa rispondono alla partita; nessuno "risponde" al
|
||||||
|
// compleanno, perché non richiede risposta.
|
||||||
|
const rispondenti = giocatori.slice(0, 12);
|
||||||
|
const righe = rispondenti.map((g, i) => ({
|
||||||
|
evento_id: partitaId,
|
||||||
|
giocatore_id: g.id,
|
||||||
|
stato: i % 2 === 0 ? "presente" : "forse",
|
||||||
|
}));
|
||||||
|
const inseriteRisposte = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inseriteRisposte.ok) {
|
||||||
|
throw new Error(`inserimento presenze fallito: ${await inseriteRisposte.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
|
(e) => e.id === partitaId || e.id === compleannoId,
|
||||||
|
);
|
||||||
|
const presenzeReali = await leggiPresenze(partitaId);
|
||||||
|
|
||||||
|
const o2 = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o2")!;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
o2.valore,
|
||||||
|
Math.round((12 / giocatori.length) * 100),
|
||||||
|
"12 risposte reali su un solo evento che le richiede (il compleanno è escluso)",
|
||||||
|
);
|
||||||
|
assert.equal(o2.scadenza, "2099-03-31", "scadenza o2 = ultimo giorno del mese iniettato");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
|
||||||
|
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
|
||||||
|
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
|
||||||
|
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
|
||||||
|
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
|
||||||
|
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||||
|
const partitaId = `${PREFISSO}-o7-partita`;
|
||||||
|
const OGGI_STR = "2099-01-20";
|
||||||
|
|
||||||
|
for (const [id, tipo, data] of [
|
||||||
|
[allenamentoId, "allenamento", "2099-01-05"],
|
||||||
|
[partitaId, "partita", "2099-01-08"],
|
||||||
|
] as const) {
|
||||||
|
const inserito = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
|
||||||
|
});
|
||||||
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
|
||||||
|
// il ritardo conta). g3: assente a entrambi (0 presenze).
|
||||||
|
const [g1, g2, g3] = giocatori;
|
||||||
|
const righe = [
|
||||||
|
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
|
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
||||||
|
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
];
|
||||||
|
const inserite = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
|
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||||
|
);
|
||||||
|
const presenzeReali = {
|
||||||
|
...(await leggiPresenze(allenamentoId)),
|
||||||
|
...(await leggiPresenze(partitaId)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
|
||||||
|
...g,
|
||||||
|
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
|
||||||
|
eventi: [],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
}).find((o) => o.id === "o7")!;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
o7.valore,
|
||||||
|
4,
|
||||||
|
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
||||||
|
const matchId = `${PREFISSO}-o12-m1`;
|
||||||
|
const [g1, g2, g3] = giocatori;
|
||||||
|
// 7 + 7 + 9 = 23 -> media 7.666... arrotondata a 7.7. Vincoli reali della tabella:
|
||||||
|
// niente autovoto (pagelle_no_autovoto), voto 1-10 (pagelle_voto_range).
|
||||||
|
const voti = [
|
||||||
|
{ match_id: matchId, votante_id: g1!.id, votato_id: g2!.id, voto: 7 },
|
||||||
|
{ match_id: matchId, votante_id: g2!.id, votato_id: g1!.id, voto: 7 },
|
||||||
|
{ match_id: matchId, votante_id: g3!.id, votato_id: g1!.id, voto: 9 },
|
||||||
|
];
|
||||||
|
const inseriti = await rest("pagelle_voti", { method: "POST", body: JSON.stringify(voti) });
|
||||||
|
if (!inseriti.ok) throw new Error(`inserimento pagelle fallito: ${await inseriti.text()}`);
|
||||||
|
|
||||||
|
const lette = await rest(
|
||||||
|
`pagelle_voti?match_id=eq.${matchId}&select=match_id,votante_id,votato_id,voto`,
|
||||||
|
);
|
||||||
|
const pagelleReali = (await lette.json()) as Array<{
|
||||||
|
match_id: string;
|
||||||
|
votante_id: string;
|
||||||
|
votato_id: string;
|
||||||
|
voto: number;
|
||||||
|
}>;
|
||||||
|
assert.equal(pagelleReali.length, 3, "i tre voti sono stati scritti e riletti dal database");
|
||||||
|
|
||||||
|
const obiettivi = obiettiviSquadra(giocatori, {
|
||||||
|
eventi: [],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: pagelleReali,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
obiettivi.find((o) => o.id === "o12")!.valore,
|
||||||
|
7.7,
|
||||||
|
"media dei voti reali, arrotondata a una cifra decimale",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
obiettivi.find((o) => o.id === "o13")!.valore,
|
||||||
|
3,
|
||||||
|
"conteggio dei voti reali scritti sul database",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"o11 conta chi ha almeno 3 allenamenti consecutivi, calcolati da eventi/risposte reali",
|
||||||
|
async () => {
|
||||||
|
// o11 non calcola nulla da `ctx`: legge `g.serieAllenamenti`, un campo già calcolato a
|
||||||
|
// monte da `serieConsecutiva()` (la stessa funzione pura usata da `useRosa()` in
|
||||||
|
// produzione). Qui si esercita l'intera catena DB -> serieConsecutiva -> o11.
|
||||||
|
const OGGI_STR = "2098-01-20";
|
||||||
|
const eventi = [
|
||||||
|
[`${PREFISSO}-o11-a1`, "2098-01-05"],
|
||||||
|
[`${PREFISSO}-o11-a2`, "2098-01-08"],
|
||||||
|
[`${PREFISSO}-o11-a3`, "2098-01-11"],
|
||||||
|
] as const;
|
||||||
|
for (const [id, data] of eventi) {
|
||||||
|
const inserito = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ id, tipo: "allenamento", titolo: "Test obiettivi o11", data }),
|
||||||
|
});
|
||||||
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [g1, g2, g3] = giocatori;
|
||||||
|
// g1: presente ai tre allenamenti -> serie 3 (conta). g2: presente, presente, assente
|
||||||
|
// -> la serie si azzera all'ultimo (non conta). g3: assente, assente, presente -> serie
|
||||||
|
// 1 (non basta).
|
||||||
|
const stati: Record<string, [string, string, string]> = {
|
||||||
|
[g1!.id]: ["presente", "presente", "presente"],
|
||||||
|
[g2!.id]: ["presente", "presente", "assente"],
|
||||||
|
[g3!.id]: ["assente", "assente", "presente"],
|
||||||
|
};
|
||||||
|
const righe = Object.entries(stati).flatMap(([giocatoreId, statiPerEvento]) =>
|
||||||
|
eventi.map(([eventoId], i) => ({
|
||||||
|
evento_id: eventoId,
|
||||||
|
giocatore_id: giocatoreId,
|
||||||
|
stato: statiPerEvento[i],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const inserite = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||||
|
|
||||||
|
const eventiReali = (await leggiEventi()).filter((e) => eventi.some(([id]) => id === e.id));
|
||||||
|
const presenzeReali: MappaPresenze = {};
|
||||||
|
for (const [id] of eventi) Object.assign(presenzeReali, await leggiPresenze(id));
|
||||||
|
|
||||||
|
const rosaConSerieReali = [g1!, g2!, g3!].map((g) => ({
|
||||||
|
...g,
|
||||||
|
serieAllenamenti: serieConsecutiva(
|
||||||
|
g.id,
|
||||||
|
eventiReali,
|
||||||
|
presenzeReali,
|
||||||
|
"allenamento",
|
||||||
|
OGGI_STR,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const o11 = obiettiviSquadra(rosaConSerieReali, {
|
||||||
|
eventi: [],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
}).find((o) => o.id === "o11")!;
|
||||||
|
assert.equal(
|
||||||
|
o11.valore,
|
||||||
|
1,
|
||||||
|
"solo g1 (presente ai tre allenamenti) resta in serie, calcolato dal database",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`pagelle_voti?match_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
riepilogo("obiettivi");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Badge Pagellone end-to-end contro il database locale: `bun test/integration/pagella-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/pagelle.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `mediePagelle()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui invece
|
||||||
|
* si scrivono voti veri su `pagelle_voti`, si rileggono via REST con la stessa selezione di
|
||||||
|
* `usePagelle()`, e si passa il risultato attraverso `mediePagelle()` fino a `statoBadge()` sul
|
||||||
|
* badge `pagella`: se una colonna cambia nome o la mappatura si rompe, qui il grado del badge
|
||||||
|
* torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano mai il
|
||||||
|
* database.
|
||||||
|
*
|
||||||
|
* Copre in particolare la soglia minima di voti (`VOTI_MINIMI_PAGELLA`, aggiunta per il gap
|
||||||
|
* "un solo voto sblocca/toglie il badge" segnalato in `docs/modules/badge.md`): con voti reali
|
||||||
|
* letti dal database, non solo con numeri scelti a mano.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-pagella-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
||||||
|
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
|
||||||
|
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 Pagellone sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("pagella-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Pagellone su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-pagella-badge";
|
||||||
|
const pagellaDef = badgeDefs.find((b) => b.id === "pagella")!;
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upsert(riga: VotoPagella) {
|
||||||
|
const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify(riga),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come `usePagelle()`. */
|
||||||
|
async function leggiVoti(): Promise<VotoPagella[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as VotoPagella[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||||
|
function giocatoreAzzerato(mediaVoto: number, votiPagella: number): Giocatore {
|
||||||
|
return { ...giocatori[0]!, mediaVoto, votiPagella, mvp: 0, palloni: 0, presenze: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
`sotto ${VOTI_MINIMI_PAGELLA} voti il badge resta bloccato anche con media alta`,
|
||||||
|
async () => {
|
||||||
|
// "pg1" riceve 4 voti da 9-10 (media altissima) ma sotto la soglia minima di voti:
|
||||||
|
// il badge non deve sbloccarsi nonostante la media sarebbe oro.
|
||||||
|
for (const [i, votante] of ["va", "vb", "vc", "vd"].entries()) {
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m${i + 1}`,
|
||||||
|
votante_id: votante,
|
||||||
|
votato_id: "pg1",
|
||||||
|
voto: 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const voti = await leggiVoti();
|
||||||
|
const medie = mediePagelle(voti);
|
||||||
|
assert.equal(medie["pg1"]?.voti, 4, "4 voti scritti, 4 riletti");
|
||||||
|
assert.equal(medie["pg1"]?.media, 9, "media alta");
|
||||||
|
|
||||||
|
const badgePg1 = statoBadge(
|
||||||
|
pagellaDef,
|
||||||
|
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
|
||||||
|
);
|
||||||
|
assert.equal(badgePg1.grado, null, "4 voti < 5: il badge resta bloccato");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
`al ${VOTI_MINIMI_PAGELLA}° voto il badge Pagellone si sblocca con il grado giusto`,
|
||||||
|
async () => {
|
||||||
|
// Un quinto voto a "pg1" (stesso giocatore del test precedente) fa scattare la
|
||||||
|
// soglia minima: la media (9) sblocca subito l'oro (soglia 8.5).
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-m5`,
|
||||||
|
votante_id: "ve",
|
||||||
|
votato_id: "pg1",
|
||||||
|
voto: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const medie = mediePagelle(await leggiVoti());
|
||||||
|
assert.equal(medie["pg1"]?.voti, 5);
|
||||||
|
const badgePg1 = statoBadge(
|
||||||
|
pagellaDef,
|
||||||
|
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
|
||||||
|
);
|
||||||
|
assert.equal(badgePg1.grado, "oro", "5 voti raggiunti: la media conta, è oro");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("le soglie di grado normali si applicano sopra il minimo di voti", async () => {
|
||||||
|
// "pg2" riceve 5 voti che fanno una media di bronzo (6.5), non oro: verifica che sopra
|
||||||
|
// la soglia minima il grado dipenda ancora dalla media, non solo dal numero di voti.
|
||||||
|
for (const [i, votante] of ["va", "vb", "vc", "vd", "ve"].entries()) {
|
||||||
|
await upsert({
|
||||||
|
match_id: `${PREFISSO}-n${i + 1}`,
|
||||||
|
votante_id: votante,
|
||||||
|
votato_id: "pg2",
|
||||||
|
voto: i < 4 ? 6 : 9, // (6*4+9)/5 = 6.6 -> arrotondato 6.6, sopra 6.5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const medie = mediePagelle(await leggiVoti());
|
||||||
|
assert.equal(medie["pg2"]?.voti, 5);
|
||||||
|
assert.equal(medie["pg2"]?.media, 6.6);
|
||||||
|
const badgePg2 = statoBadge(
|
||||||
|
pagellaDef,
|
||||||
|
giocatoreAzzerato(medie["pg2"]!.media, medie["pg2"]!.voti),
|
||||||
|
);
|
||||||
|
assert.equal(badgePg2.grado, "bronzo", "media 6.6: bronzo, non oro");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("pagella-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Badge Sherpa dei palloni end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/palloni-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/palloni-core.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `conteggioTurni()` e `statoBadge()` come funzioni pure, con eventi e turni costruiti a mano.
|
||||||
|
* Qui invece si scrivono eventi e turni veri su `eventi_app`/`turni_palloni`, si rileggono via
|
||||||
|
* REST con la stessa forma di `fetchTurni()`/`daRiga()`, e si passa il risultato attraverso
|
||||||
|
* `conteggioTurni()` fino a `statoBadge()` sul badge `palloni`: se una colonna cambia nome o la
|
||||||
|
* mappatura si rompe, qui il grado del badge torna sbagliato anche se i test unitari restano
|
||||||
|
* verdi, perché quelli non toccano mai il database.
|
||||||
|
*
|
||||||
|
* Verifica in particolare che il badge conti **solo i turni confermati**
|
||||||
|
* (`Giocatore.palloni` in `rosa.ts` usa `turniSalvati`, non l'output di `completaTurni()`):
|
||||||
|
* un evento passato senza turno confermato non deve avanzare il badge di nessuno, anche se
|
||||||
|
* `completaTurni()` (usato altrove, per la UI di rotazione) lo proporrebbe automaticamente a
|
||||||
|
* qualcuno. Prima di questo fix il badge includeva anche le proposte non confermate — vedi
|
||||||
|
* `docs/modules/badge.md` § Limiti noti (risolto) per il dettaglio del bug e della correzione.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-palloni-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
|
import { conteggioTurni } from "@/lib/palloni-core";
|
||||||
|
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 palloni sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("palloni-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge palloni su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-palloni-badge";
|
||||||
|
const palloniDef = badgeDefs.find((b) => b.id === "palloni")!;
|
||||||
|
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono tutti datati nel passato remoto,
|
||||||
|
// così restano "passati" (e 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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function creaEvento(id: string, data: string) {
|
||||||
|
const res = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({ id, tipo: "partita", titolo: `Partita ${id}`, data }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confermaTurno(eventoId: string, giocatoreId: string) {
|
||||||
|
const res = await rest("turni_palloni?on_conflict=evento_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su turni_palloni: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi, una mappa per i turni. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come `fetchTurni()`: solo i turni confermati, nessuna proposta. */
|
||||||
|
async function leggiTurniSalvati(): Promise<Record<string, string>> {
|
||||||
|
const res = await rest(
|
||||||
|
`turni_palloni?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{ evento_id: string; giocatore_id: string }>;
|
||||||
|
const mappa: Record<string, string> = {};
|
||||||
|
for (const r of righe) mappa[r.evento_id] = r.giocatore_id;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||||
|
function giocatoreAzzerato(palloni: number): Giocatore {
|
||||||
|
return { ...giocatori[0]!, palloni, mvp: 0, mediaVoto: 0, votiPagella: 0, presenze: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il badge palloni passa da bronzo ad argento con turni reali, ignorando gli eventi mai confermati",
|
||||||
|
async () => {
|
||||||
|
// m1-m3: turno confermato esplicitamente a pv1 (3 turni -> soglia bronzo).
|
||||||
|
// m4: NESSUN turno confermato -> non deve contare per nessuno, anche se un algoritmo
|
||||||
|
// di rotazione (usato altrove per la UI) proporrebbe qualcuno in automatico.
|
||||||
|
// m5-m7: confermati di nuovo a pv1 (6 turni totali -> soglia argento).
|
||||||
|
const eventi = [
|
||||||
|
[`${PREFISSO}-m1`, "2020-01-01", "pv1"],
|
||||||
|
[`${PREFISSO}-m2`, "2020-01-02", "pv1"],
|
||||||
|
[`${PREFISSO}-m3`, "2020-01-03", "pv1"],
|
||||||
|
[`${PREFISSO}-m4`, "2020-01-04", null],
|
||||||
|
[`${PREFISSO}-m5`, "2020-01-05", "pv1"],
|
||||||
|
[`${PREFISSO}-m6`, "2020-01-06", "pv1"],
|
||||||
|
[`${PREFISSO}-m7`, "2020-01-07", "pv1"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
for (const [id, data] of eventi.slice(0, 3)) await creaEvento(id, data);
|
||||||
|
for (const [id, , giocatore] of eventi.slice(0, 3))
|
||||||
|
if (giocatore) await confermaTurno(id, giocatore);
|
||||||
|
|
||||||
|
const eventiA3 = await leggiEventi();
|
||||||
|
const salvatiA3 = await leggiTurniSalvati();
|
||||||
|
const conteggioA3 = conteggioTurni(salvatiA3, eventiA3, OGGI);
|
||||||
|
assert.equal(conteggioA3["pv1"], 3, "3 turni confermati: soglia bronzo appena raggiunta");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(palloniDef, giocatoreAzzerato(conteggioA3["pv1"]!)).grado,
|
||||||
|
"bronzo",
|
||||||
|
"3 turni: bronzo, non oltre (soglia argento è 6)",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [id, data] of eventi.slice(3)) await creaEvento(id, data);
|
||||||
|
for (const [id, , giocatore] of eventi.slice(3))
|
||||||
|
if (giocatore) await confermaTurno(id, giocatore);
|
||||||
|
|
||||||
|
const eventiLetti = await leggiEventi();
|
||||||
|
assert.equal(eventiLetti.length, 7, "tutti gli eventi scritti si rileggono");
|
||||||
|
|
||||||
|
const salvati = await leggiTurniSalvati();
|
||||||
|
assert.equal(Object.keys(salvati).length, 6, "6 turni confermati esplicitamente, m4 no");
|
||||||
|
|
||||||
|
const conteggio = conteggioTurni(salvati, eventiLetti, OGGI);
|
||||||
|
assert.equal(conteggio["pv1"], 6, "6 turni confermati per pv1");
|
||||||
|
assert.equal(
|
||||||
|
conteggio["pv2"] ?? 0,
|
||||||
|
0,
|
||||||
|
"pv2 non ha mai confermato nulla: m4 senza conferma non conta per nessuno",
|
||||||
|
);
|
||||||
|
|
||||||
|
const badgePv1 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv1"]!));
|
||||||
|
assert.equal(badgePv1.grado, "argento", "6 turni: soglia argento raggiunta");
|
||||||
|
|
||||||
|
const badgePv2 = statoBadge(palloniDef, giocatoreAzzerato(conteggio["pv2"] ?? 0));
|
||||||
|
assert.equal(badgePv2.grado, null, "zero turni confermati: nessun badge");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("un evento futuro non riceve conteggio, anche se già confermato", async () => {
|
||||||
|
// Stesso scenario di sopra ma con "oggi" prima di tutti gli eventi: nessun turno
|
||||||
|
// confermato deve ancora contare per il badge.
|
||||||
|
const eventiLetti = await leggiEventi();
|
||||||
|
const salvati = await leggiTurniSalvati();
|
||||||
|
const conteggio = conteggioTurni(salvati, eventiLetti, "2019-01-01");
|
||||||
|
assert.deepEqual(conteggio, {}, "tutti gli eventi sono nel futuro rispetto a 'oggi'");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`turni_palloni?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("palloni-badge");
|
||||||
|
}
|
||||||
@@ -311,6 +311,18 @@ if (!locale) {
|
|||||||
});
|
});
|
||||||
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
|
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
|
||||||
|
|
||||||
|
const mioMvp = await rest("mvp_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO,
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g5",
|
||||||
|
votato_nome: "Cinque",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
|
||||||
|
|
||||||
const mvp = await rest("mvp_voti", tokenGiocatore, {
|
const mvp = await rest("mvp_voti", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
@@ -394,6 +406,142 @@ if (!locale) {
|
|||||||
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
|
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// M13: le tabelle di voto controllano anche a database chi può votare chi, non solo
|
||||||
|
// chi firma il voto. Prima di M13 un convocato poteva votare/essere votato in un
|
||||||
|
// evento a cui non aveva partecipato, e un voto pagella restava possibile anche a
|
||||||
|
// `pagelle_chiuse` — entrambi filtri solo applicativi (segnalati in `badge.md`).
|
||||||
|
// Nota: `tokenAdmin` non va usato per queste prove, la policy admin di M11 non ha il
|
||||||
|
// controllo sui convocati (l'admin corregge anche dati fuori convocazione di
|
||||||
|
// proposito) e farebbe passare tutto a prescindere, senza provare niente sulla nuova
|
||||||
|
// policy. Si usa solo `tokenGiocatore` (g1), un votante non-admin vero.
|
||||||
|
const EVENTO_SENZA_G1 = `${PREFISSO}-evento-senza-g1`;
|
||||||
|
const EVENTO_CON_G1 = `${PREFISSO}-evento-con-g1`;
|
||||||
|
const EVENTO_CHIUSO = `${PREFISSO}-evento-chiuso`;
|
||||||
|
|
||||||
|
await prova("un votante non convocato non può votare", async () => {
|
||||||
|
const creato = await rest("eventi_app", tokenAdmin, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: EVENTO_SENZA_G1,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: "Partita senza g1 tra i convocati",
|
||||||
|
data: "2026-01-02",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "Palestra",
|
||||||
|
convocati: ["g2", "g5"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
|
||||||
|
|
||||||
|
const votanteEscluso = await rest("pagelle_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO_SENZA_G1,
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g2",
|
||||||
|
voto: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(!votanteEscluso.ok, `g1 non era convocato, non vota (${votanteEscluso.status})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("un votante convocato non può votare chi non lo era", async () => {
|
||||||
|
const creato = await rest("eventi_app", tokenAdmin, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: EVENTO_CON_G1,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: "Partita con g1 convocato, g2 no",
|
||||||
|
data: "2026-01-02",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "Palestra",
|
||||||
|
convocati: ["g1", "g5"],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
|
||||||
|
|
||||||
|
const votatoEscluso = await rest("badge_social_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO_CON_G1,
|
||||||
|
categoria: "cuore",
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
!votatoEscluso.ok,
|
||||||
|
`g2 non era convocato, non è votabile (${votatoEscluso.status})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
||||||
|
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
||||||
|
// (es. un evento inesistente) e sembrare comunque corretto.
|
||||||
|
const votoValido = await rest("mvp_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO_CON_G1,
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g5",
|
||||||
|
votato_nome: "Cinque",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(votoValido), 1, "votante e votato convocati: il voto passa");
|
||||||
|
});
|
||||||
|
|
||||||
|
await prova("pagelle_chiuse blocca anche a database, non solo in UI", async () => {
|
||||||
|
const creato = await rest("eventi_app", tokenAdmin, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: EVENTO_CHIUSO,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: "Partita con pagelle chiuse",
|
||||||
|
data: "2026-01-03",
|
||||||
|
ora: "20:00",
|
||||||
|
luogo: "Palestra",
|
||||||
|
pagelle_chiuse: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(creato), 1, "l'evento con pagelle chiuse si crea");
|
||||||
|
|
||||||
|
const pagellaFuoriTempo = await rest("pagelle_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO_CHIUSO,
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g5",
|
||||||
|
voto: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
!pagellaFuoriTempo.ok,
|
||||||
|
`pagelle chiuse: voto rifiutato (${pagellaFuoriTempo.status})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
|
||||||
|
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
|
||||||
|
// votabili sullo stesso evento.
|
||||||
|
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
match_id: EVENTO_CHIUSO,
|
||||||
|
votante_id: "g1",
|
||||||
|
votato_id: "g5",
|
||||||
|
votato_nome: "Cinque",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(await righeToccate(mvpAncoraAperto), 1, "l'MVP non ha un flag di chiusura");
|
||||||
|
});
|
||||||
|
|
||||||
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
|
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
|
||||||
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
|
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
|
||||||
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
|
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Badge Presenza fissa end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/presenze-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure, con eventi e risposte
|
||||||
|
* costruiti a mano. Qui invece si scrivono eventi e risposte veri su `eventi_app`/
|
||||||
|
* `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/
|
||||||
|
* `daRiga()`, e si passa il risultato attraverso `contaPresenzeGiocatore()` fino a
|
||||||
|
* `statoBadge()` sul badge `presenze`: se una colonna cambia nome o la mappatura si rompe, qui
|
||||||
|
* il grado del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non
|
||||||
|
* toccano mai il database.
|
||||||
|
*
|
||||||
|
* Copre anche un punto verificato nell'analisi ma non ovvio: **non serve nessun controllo a
|
||||||
|
* database sui convocati** per questo badge (a differenza di MVP/pagelle/badge social, vedi
|
||||||
|
* M13 in `docs/modules/badge.md`) perché `contaPresenzeGiocatore()` filtra già gli eventi per
|
||||||
|
* `convocati` lato applicazione — una risposta scritta per un evento a cui non si era
|
||||||
|
* convocati non conta comunque, anche se la riga esiste nel database.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-presenze-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
|
import { 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";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("badge presenze sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("presenze-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge presenze su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-presenze-badge";
|
||||||
|
const presenzeDef = badgeDefs.find((b) => b.id === "presenze")!;
|
||||||
|
// "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);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function creaEvento(id: string, i: number, convocati?: string[]) {
|
||||||
|
const res = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: `Allenamento ${id}`,
|
||||||
|
data: dataEvento(i),
|
||||||
|
...(convocati ? { convocati } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(eventoId: string, giocatoreId: string, stato: 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 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come fa l'app: `daRiga()` per gli eventi. */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rilegge esattamente come `fetchPresenze()`. */
|
||||||
|
async function leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: string;
|
||||||
|
}>;
|
||||||
|
const mappa: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||||
|
function giocatoreAzzerato(presenze: number): Giocatore {
|
||||||
|
return { ...giocatori[0]!, presenze, mvp: 0, mediaVoto: 0, votiPagella: 0, palloni: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il badge presenze passa da bronzo a oro con presenze vere lette dal database",
|
||||||
|
async () => {
|
||||||
|
// 30 allenamenti passati. "pp1" risponde a tutti e 30 (i primi 5 "presente", un
|
||||||
|
// "ritardo" nel mezzo per verificare che conti come presenza, il resto "presente"),
|
||||||
|
// così una sola scrittura basta a verificare le tre soglie 5/15/30.
|
||||||
|
for (let i = 1; i <= 30; i += 1) {
|
||||||
|
await creaEvento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i);
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= 30; i += 1) {
|
||||||
|
const stato = i === 10 ? "ritardo" : "presente";
|
||||||
|
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp1", stato);
|
||||||
|
}
|
||||||
|
// "pp2" risponde solo ai primi 5: resta a bronzo, non oltre.
|
||||||
|
for (let i = 1; i <= 5; i += 1) {
|
||||||
|
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente");
|
||||||
|
}
|
||||||
|
// "pp2" risponde anche "assente" a un evento: non deve contare.
|
||||||
|
await rispondi(`${PREFISSO}-a06`, "pp2", "assente");
|
||||||
|
|
||||||
|
const eventi = await leggiEventi();
|
||||||
|
assert.equal(eventi.length, 30, "tutti gli eventi scritti si rileggono");
|
||||||
|
const presenze = await leggiPresenze();
|
||||||
|
|
||||||
|
const contoPp1 = contaPresenzeGiocatore("pp1", eventi, presenze, OGGI);
|
||||||
|
assert.equal(contoPp1, 30, "30 risposte, incluso un ritardo contato come presenza");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(presenzeDef, giocatoreAzzerato(contoPp1)).grado,
|
||||||
|
"oro",
|
||||||
|
"30 presenze: soglia oro raggiunta",
|
||||||
|
);
|
||||||
|
|
||||||
|
const contoPp2 = contaPresenzeGiocatore("pp2", eventi, presenze, OGGI);
|
||||||
|
assert.equal(contoPp2, 5, "5 presenti, l'assente su a06 non conta");
|
||||||
|
const badgePp2 = statoBadge(presenzeDef, giocatoreAzzerato(contoPp2));
|
||||||
|
assert.equal(badgePp2.grado, "bronzo", "5 presenze: soglia bronzo raggiunta, non oltre");
|
||||||
|
assert.equal(badgePp2.prossimo, "argento");
|
||||||
|
assert.equal(badgePp2.prossimaSoglia, 15);
|
||||||
|
|
||||||
|
// Un pp2 a metà strada (15 presenze) sblocca l'argento.
|
||||||
|
for (let i = 7; i <= 16; i += 1) {
|
||||||
|
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "pp2", "presente");
|
||||||
|
}
|
||||||
|
const eventiDopo = await leggiEventi();
|
||||||
|
const presenzeDopo = await leggiPresenze();
|
||||||
|
const contoPp2Dopo = contaPresenzeGiocatore("pp2", eventiDopo, presenzeDopo, OGGI);
|
||||||
|
assert.equal(contoPp2Dopo, 15, "5 + 10 nuove presenze");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(presenzeDef, giocatoreAzzerato(contoPp2Dopo)).grado,
|
||||||
|
"argento",
|
||||||
|
"15 presenze: soglia argento raggiunta",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"una risposta a un evento senza convocazione non conta per il badge, anche se la riga esiste",
|
||||||
|
async () => {
|
||||||
|
// "pp3" non è tra i convocati di questo evento (lista esplicita che lo esclude), ma
|
||||||
|
// scrive comunque una risposta "presente" — bypassando l'interfaccia, come farebbe
|
||||||
|
// chi parla direttamente con PostgREST. Deve restare a zero: la difesa è nella
|
||||||
|
// funzione pura, non in una policy RLS (a differenza di MVP/pagelle/badge social).
|
||||||
|
await creaEvento(`${PREFISSO}-ristretto`, 40, ["pp1"]);
|
||||||
|
await rispondi(`${PREFISSO}-ristretto`, "pp3", "presente");
|
||||||
|
|
||||||
|
const eventi = await leggiEventi();
|
||||||
|
const presenze = await leggiPresenze();
|
||||||
|
const conto = contaPresenzeGiocatore("pp3", eventi, presenze, OGGI);
|
||||||
|
assert.equal(conto, 0, "pp3 non era convocato: la risposta non conta");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("presenze-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Pulizia a cascata dei dati collegati alla cancellazione di un evento (M14):
|
||||||
|
* `bun test/integration/pulizia-evento.test.ts`.
|
||||||
|
*
|
||||||
|
* Scrive una riga per ciascuna delle 9 tabelle collegate a un evento (`risposte_presenze`,
|
||||||
|
* `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`,
|
||||||
|
* `scout_sessioni`, `scout_live`, `scout_partite`), cancella l'evento e verifica che il
|
||||||
|
* trigger `eventi_app_pulisci_dati_collegati` le abbia rimosse tutte. Prima di M14 queste
|
||||||
|
* righe restavano orfane a database (`docs/DESIGN_DECISIONS.md`, DD-029).
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`): usa id con il prefisso
|
||||||
|
* `test-pulizia-evento`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("pulizia dati evento cancellato", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("pulizia-evento");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`pulizia dati evento cancellato su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const ID_EVENTO = "test-pulizia-evento-e1";
|
||||||
|
|
||||||
|
const rest = (percorso: string, init?: RequestInit) =>
|
||||||
|
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
Prefer: "return=minimal",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function inserisci(tabella: string, riga: Record<string, unknown>) {
|
||||||
|
const res = await rest(tabella, { method: "POST", body: JSON.stringify(riga) });
|
||||||
|
if (!res.ok) throw new Error(`insert su ${tabella}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function conta(tabella: string, filtro: string): Promise<number> {
|
||||||
|
const res = await rest(`${tabella}?${filtro}&select=*`, {
|
||||||
|
headers: { Prefer: "count=exact" },
|
||||||
|
});
|
||||||
|
return Number(res.headers.get("content-range")?.split("/")[1] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABELLE_EVENTO_ID = [
|
||||||
|
"risposte_presenze",
|
||||||
|
"cacche_partita",
|
||||||
|
"turni_palloni",
|
||||||
|
"scout_sessioni",
|
||||||
|
"scout_live",
|
||||||
|
"scout_partite",
|
||||||
|
];
|
||||||
|
const TABELLE_MATCH_ID = ["mvp_voti", "pagelle_voti", "badge_social_voti"];
|
||||||
|
|
||||||
|
async function pulisciTutto() {
|
||||||
|
await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" });
|
||||||
|
for (const t of TABELLE_EVENTO_ID) {
|
||||||
|
await rest(`${t}?evento_id=eq.${ID_EVENTO}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
await rest(`${t}?match_id=eq.${ID_EVENTO}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova("cancellare un evento pulisce a cascata tutte le tabelle collegate", async () => {
|
||||||
|
await inserisci("eventi_app", {
|
||||||
|
id: ID_EVENTO,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: "Test pulizia",
|
||||||
|
data: "2026-09-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
await inserisci("risposte_presenze", {
|
||||||
|
evento_id: ID_EVENTO,
|
||||||
|
giocatore_id: "test-g1",
|
||||||
|
stato: "presente",
|
||||||
|
});
|
||||||
|
await inserisci("cacche_partita", {
|
||||||
|
evento_id: ID_EVENTO,
|
||||||
|
giocatore_id: "test-g1",
|
||||||
|
quantita: 2,
|
||||||
|
});
|
||||||
|
await inserisci("turni_palloni", { evento_id: ID_EVENTO, giocatore_id: "test-g1" });
|
||||||
|
await inserisci("scout_sessioni", {
|
||||||
|
evento_id: ID_EVENTO,
|
||||||
|
giocatore_id: "test-g1",
|
||||||
|
giocatore_nome: "Uno",
|
||||||
|
});
|
||||||
|
await inserisci("scout_live", { evento_id: ID_EVENTO, stato: {} });
|
||||||
|
await inserisci("scout_partite", {
|
||||||
|
id: `${ID_EVENTO}-scout`,
|
||||||
|
evento_id: ID_EVENTO,
|
||||||
|
data: "2026-09-01",
|
||||||
|
avversario: "Test",
|
||||||
|
set_nostri: 3,
|
||||||
|
set_loro: 0,
|
||||||
|
});
|
||||||
|
await inserisci("mvp_voti", {
|
||||||
|
match_id: ID_EVENTO,
|
||||||
|
votante_id: "test-g1",
|
||||||
|
votato_id: "test-g2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
await inserisci("pagelle_voti", {
|
||||||
|
match_id: ID_EVENTO,
|
||||||
|
votante_id: "test-g1",
|
||||||
|
votato_id: "test-g2",
|
||||||
|
voto: 7,
|
||||||
|
});
|
||||||
|
await inserisci("badge_social_voti", {
|
||||||
|
match_id: ID_EVENTO,
|
||||||
|
categoria: "top",
|
||||||
|
votante_id: "test-g1",
|
||||||
|
votato_id: "test-g2",
|
||||||
|
votato_nome: "Due",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tutte le righe esistono prima della cancellazione.
|
||||||
|
for (const t of TABELLE_EVENTO_ID) {
|
||||||
|
assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`);
|
||||||
|
}
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 1, `${t}: riga presente`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await rest(`eventi_app?id=eq.${ID_EVENTO}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error(`delete evento: ${res.status} ${await res.text()}`);
|
||||||
|
|
||||||
|
// Il trigger deve aver ripulito tutte le righe collegate.
|
||||||
|
for (const t of TABELLE_EVENTO_ID) {
|
||||||
|
assert.equal(await conta(t, `evento_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`);
|
||||||
|
}
|
||||||
|
for (const t of TABELLE_MATCH_ID) {
|
||||||
|
assert.equal(await conta(t, `match_id=eq.${ID_EVENTO}`), 0, `${t}: pulita a cascata`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await pulisciTutto();
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("pulizia-evento");
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Badge segreto Trono di ferro end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/s-cacche-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/cacche.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `statisticheCacche()` e `statoBadge()` come funzioni pure. Qui invece si scrivono righe vere
|
||||||
|
* su `cacche_partita`, si rileggono via REST con la stessa selezione di `useCacche()`, e si
|
||||||
|
* passa il risultato attraverso `statisticheCacche()` fino a `statoBadge()` sul badge segreto
|
||||||
|
* `s-cacche`.
|
||||||
|
*
|
||||||
|
* Copre in particolare un comportamento verificato in analisi e confermato intenzionale (non
|
||||||
|
* un bug): **la descrizione del badge non distingue più campionato da amichevoli** ("almeno 3
|
||||||
|
* partite affrontate con 3+ cacche pre-gara, campionato o amichevole") perché nessuna funzione
|
||||||
|
* della pipeline (`statisticheCacche()`, `rosa.ts`, `badges.ts`) filtra mai su
|
||||||
|
* `eventi_app.campionato` — la vecchia descrizione prometteva "partite di campionato" senza
|
||||||
|
* che il codice lo verificasse mai. Qui si scrive apposta un evento con `campionato=false`
|
||||||
|
* (amichevole) e si dimostra che conta lo stesso, così un domani chi reintroduce un filtro sul
|
||||||
|
* campionato deve toccare anche questo test, non scoprirlo in produzione.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-s-cacche-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeSegreti, statoBadge } from "@/lib/badges";
|
||||||
|
import { statisticheCacche, type RigaCacche } from "@/lib/cacche";
|
||||||
|
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 Trono di ferro sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("s-cacche-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Trono di ferro su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-s-cacche-badge";
|
||||||
|
const def = badgeSegreti.find((b) => b.id === "s-cacche")!;
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function creaEvento(id: string, campionato: boolean) {
|
||||||
|
const res = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
tipo: "partita",
|
||||||
|
titolo: `Partita ${id}`,
|
||||||
|
data: "2020-01-01",
|
||||||
|
campionato,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dichiara(eventoId: string, giocatoreId: string, quantita: number) {
|
||||||
|
const res = await rest("cacche_partita?on_conflict=evento_id,giocatore_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({ evento_id: eventoId, giocatore_id: giocatoreId, quantita }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su cacche_partita: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiCacche(): Promise<RigaCacche[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`cacche_partita?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,quantita`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as RigaCacche[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(cacche: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
cacche,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
presenze: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il segreto si sblocca con 3 giornate da record, comprese le amichevoli",
|
||||||
|
async () => {
|
||||||
|
// sc1: 2 giornate top su partite di campionato, 1 su un'amichevole. Il badge non fa
|
||||||
|
// distinzione: le 3 contano tutte allo stesso modo.
|
||||||
|
await creaEvento(`${PREFISSO}-c1`, true);
|
||||||
|
await creaEvento(`${PREFISSO}-c2`, true);
|
||||||
|
await creaEvento(`${PREFISSO}-a1`, false); // amichevole
|
||||||
|
await dichiara(`${PREFISSO}-c1`, "sc1", 3);
|
||||||
|
await dichiara(`${PREFISSO}-c2`, "sc1", 4);
|
||||||
|
|
||||||
|
const righeA2 = await leggiCacche();
|
||||||
|
const statsA2 = statisticheCacche(righeA2);
|
||||||
|
assert.equal(statsA2["sc1"]?.giornateTop, 2, "solo le due di campionato per ora");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(statsA2["sc1"]!.giornateTop)).grado,
|
||||||
|
null,
|
||||||
|
"2 giornate top non bastano",
|
||||||
|
);
|
||||||
|
|
||||||
|
await dichiara(`${PREFISSO}-a1`, "sc1", 3);
|
||||||
|
const righe = await leggiCacche();
|
||||||
|
const stats = statisticheCacche(righe);
|
||||||
|
assert.equal(stats["sc1"]?.giornateTop, 3, "l'amichevole conta come le altre due");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(stats["sc1"]!.giornateTop)).grado,
|
||||||
|
"oro",
|
||||||
|
"3 giornate top, campionato o amichevole: il segreto si sblocca",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`cacche_partita?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("s-cacche-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Badge segreto Cliente VIP dell'Infermeria end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/s-infermeria-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/infortuni.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `contaInfortuni()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
|
||||||
|
* mano. Qui invece si scrivono eventi e risposte "infortunato" veri su `eventi_app`/
|
||||||
|
* `risposte_presenze`, si rileggono via REST con la stessa forma di `daRiga()`/
|
||||||
|
* `fetchPresenze()`, e si passa il risultato attraverso `contaInfortuni()` fino a
|
||||||
|
* `statoBadge()` sul badge segreto `s-infermeria`: se una colonna cambia nome o la mappatura
|
||||||
|
* si rompe, qui il badge torna sbagliato anche se i test unitari restano verdi.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-s-infermeria-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeSegreti, statoBadge } from "@/lib/badges";
|
||||||
|
import { contaInfortuni } from "@/lib/infortuni";
|
||||||
|
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 Cliente VIP dell'Infermeria sul database",
|
||||||
|
"stack locale non attivo (npx supabase start)",
|
||||||
|
);
|
||||||
|
riepilogo("s-infermeria-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Cliente VIP dell'Infermeria su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-s-infermeria-badge";
|
||||||
|
const def = badgeSegreti.find((b) => b.id === "s-infermeria")!;
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(eventoId: string, giocatoreId: string, stato: 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 }),
|
||||||
|
});
|
||||||
|
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 leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: string;
|
||||||
|
}>;
|
||||||
|
const mappa: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(infortuni: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
infortuni,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
presenze: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova("il segreto si sblocca al terzo infortunio vero, non prima", async () => {
|
||||||
|
for (let i = 1; i <= 3; i += 1) {
|
||||||
|
const id = `${PREFISSO}-e${i}`;
|
||||||
|
await creaEvento(id, i);
|
||||||
|
await rispondi(id, "si1", "infortunato");
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventiA2 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e3`);
|
||||||
|
const presenzeA2 = await leggiPresenze();
|
||||||
|
const contoA2 = contaInfortuni(presenzeA2, eventiA2, OGGI)["si1"] ?? 0;
|
||||||
|
assert.equal(contoA2, 2, "solo i primi due eventi contati");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(contoA2)).grado,
|
||||||
|
null,
|
||||||
|
"2 infortuni non bastano",
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventi = await leggiEventi();
|
||||||
|
const presenze = await leggiPresenze();
|
||||||
|
const conto = contaInfortuni(presenze, eventi, OGGI)["si1"] ?? 0;
|
||||||
|
assert.equal(conto, 3);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(conto)).grado,
|
||||||
|
"oro",
|
||||||
|
"3 infortuni: il segreto si sblocca (soglia unica)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("s-infermeria-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
|
||||||
|
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: Record<string, Record<string, string>>;
|
||||||
|
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: string;
|
||||||
|
risposto_il: string;
|
||||||
|
}>;
|
||||||
|
const presenze: Record<string, Record<string, string>> = {};
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Badge segreto Aspettate, arrivo! end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/s-ritardi-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* Stessa struttura di `s-infermeria-badge.test.ts` ma sullo stato "ritardo" invece di
|
||||||
|
* "infortunato": scrive eventi e risposte veri su `eventi_app`/`risposte_presenze`, rilegge
|
||||||
|
* via REST e passa il risultato attraverso `contaRitardi()` fino a `statoBadge()` sul badge
|
||||||
|
* segreto `s-ritardi` (soglia 5, unica).
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-s-ritardi-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeSegreti, statoBadge } from "@/lib/badges";
|
||||||
|
import { contaRitardi } from "@/lib/infortuni";
|
||||||
|
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 Aspettate, arrivo! sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("s-ritardi-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Aspettate, arrivo! su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-s-ritardi-badge";
|
||||||
|
const def = badgeSegreti.find((b) => b.id === "s-ritardi")!;
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(eventoId: string, giocatoreId: string, stato: 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 }),
|
||||||
|
});
|
||||||
|
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 leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: string;
|
||||||
|
}>;
|
||||||
|
const mappa: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(ritardi: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
ritardi,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
presenze: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova("il segreto si sblocca al quinto ritardo vero, non prima", async () => {
|
||||||
|
for (let i = 1; i <= 5; i += 1) {
|
||||||
|
const id = `${PREFISSO}-e${i}`;
|
||||||
|
await creaEvento(id, i);
|
||||||
|
await rispondi(id, "sr1", "ritardo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventiA4 = (await leggiEventi()).filter((e) => e.id !== `${PREFISSO}-e5`);
|
||||||
|
const presenzeA4 = await leggiPresenze();
|
||||||
|
const contoA4 = contaRitardi(presenzeA4, eventiA4, OGGI)["sr1"] ?? 0;
|
||||||
|
assert.equal(contoA4, 4, "solo i primi quattro eventi contati");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(contoA4)).grado,
|
||||||
|
null,
|
||||||
|
"4 ritardi non bastano",
|
||||||
|
);
|
||||||
|
|
||||||
|
const eventi = await leggiEventi();
|
||||||
|
const presenze = await leggiPresenze();
|
||||||
|
const conto = contaRitardi(presenze, eventi, OGGI)["sr1"] ?? 0;
|
||||||
|
assert.equal(conto, 5);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(conto)).grado,
|
||||||
|
"oro",
|
||||||
|
"5 ritardi: il segreto si sblocca (soglia unica)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("s-ritardi-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Badge segreto Uomo tie-break end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/s-tiebreak-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/mvp-voti.test.ts`, `test/unit/pagelle.test.ts`,
|
||||||
|
* `test/unit/badges.test.ts`) verificano `mvpVintiPerGiocatore()`, `mediePagelle()` e
|
||||||
|
* `statoBadge()` come funzioni pure. Qui invece si scrivono voti veri su `mvp_voti` e
|
||||||
|
* `pagelle_voti`, si rileggono via REST e si passa il risultato attraverso
|
||||||
|
* `mvpVintiPerGiocatore()`/`mediePagelle()` fino a `statoBadge()` sul badge segreto
|
||||||
|
* `s-tiebreak`.
|
||||||
|
*
|
||||||
|
* Copre in particolare il fix di questa sessione: prima `s-tiebreak` usava `g.mediaVoto` senza
|
||||||
|
* applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale Pagellone che usa lo stesso
|
||||||
|
* campo — un solo voto pagella altissimo poteva sbloccare il segreto insieme a 2 MVP, senza
|
||||||
|
* significatività statistica. Qui si dimostra con dati reali che ora serve lo stesso minimo di
|
||||||
|
* voti di Pagellone anche per questo segreto.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-s-tiebreak-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
||||||
|
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
|
||||||
|
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
|
||||||
|
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 Uomo tie-break sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("s-tiebreak-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Uomo tie-break su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-s-tiebreak-badge";
|
||||||
|
const def = badgeSegreti.find((b) => b.id === "s-tiebreak")!;
|
||||||
|
|
||||||
|
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 ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function votaMvp(riga: VotoMvp) {
|
||||||
|
const res = await rest("mvp_voti?on_conflict=match_id,votante_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify(riga),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su mvp_voti: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function votaPagella(riga: VotoPagella) {
|
||||||
|
const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify(riga),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiMvp(): Promise<VotoMvp[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as VotoMvp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiPagelle(): Promise<VotoPagella[]> {
|
||||||
|
const res = await rest(
|
||||||
|
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
|
||||||
|
);
|
||||||
|
return (await res.json()) as VotoPagella[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(mvp: number, mediaVoto: number, votiPagella: number): Giocatore {
|
||||||
|
return { ...giocatori[0]!, mvp, mediaVoto, votiPagella, palloni: 0, presenze: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"sotto la soglia minima di voti pagella, 2 MVP e media alta non bastano",
|
||||||
|
async () => {
|
||||||
|
// "tb1" vince nettamente m1 e m2 (2 MVP, due voti a testa per rispettare il quorum
|
||||||
|
// minimo), e riceve un solo voto pagella da 9 (media alta ma su un campione troppo
|
||||||
|
// piccolo): il segreto deve restare bloccato.
|
||||||
|
await votaMvp({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "tb1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await votaMvp({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "vf",
|
||||||
|
votato_id: "tb1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await votaMvp({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "tb1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await votaMvp({
|
||||||
|
match_id: `${PREFISSO}-m2`,
|
||||||
|
votante_id: "vf",
|
||||||
|
votato_id: "tb1",
|
||||||
|
votato_nome: "Uno",
|
||||||
|
});
|
||||||
|
await votaPagella({
|
||||||
|
match_id: `${PREFISSO}-m1`,
|
||||||
|
votante_id: "va",
|
||||||
|
votato_id: "tb1",
|
||||||
|
voto: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vinti = mvpVintiPerGiocatore(await leggiMvp());
|
||||||
|
const medie = mediePagelle(await leggiPagelle());
|
||||||
|
assert.equal(vinti["tb1"], 2, "2 MVP netti");
|
||||||
|
assert.equal(medie["tb1"]?.voti, 1, "un solo voto pagella");
|
||||||
|
assert.equal(medie["tb1"]?.media, 9);
|
||||||
|
|
||||||
|
const badge = statoBadge(
|
||||||
|
def,
|
||||||
|
giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
badge.grado,
|
||||||
|
null,
|
||||||
|
`sotto ${VOTI_MINIMI_PAGELLA} voti: il segreto resta bloccato`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(`al ${VOTI_MINIMI_PAGELLA}° voto pagella il segreto si sblocca`, async () => {
|
||||||
|
// Altri 4 voti pagella allo stesso "tb1", sempre alti: raggiunta la soglia minima, con
|
||||||
|
// 2 MVP e media alta il segreto si sblocca.
|
||||||
|
for (const [i, votante] of ["vb", "vc", "vd", "ve"].entries()) {
|
||||||
|
await votaPagella({
|
||||||
|
match_id: `${PREFISSO}-n${i + 1}`,
|
||||||
|
votante_id: votante,
|
||||||
|
votato_id: "tb1",
|
||||||
|
voto: 9,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const vinti = mvpVintiPerGiocatore(await leggiMvp());
|
||||||
|
const medie = mediePagelle(await leggiPagelle());
|
||||||
|
assert.equal(medie["tb1"]?.voti, 5);
|
||||||
|
assert.equal(medie["tb1"]?.media, 9);
|
||||||
|
|
||||||
|
const badge = statoBadge(
|
||||||
|
def,
|
||||||
|
giocatoreAzzerato(vinti["tb1"]!, medie["tb1"]!.media, medie["tb1"]!.voti),
|
||||||
|
);
|
||||||
|
assert.equal(badge.grado, "oro", "2 MVP, 5 voti, media 9: il segreto si sblocca");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`mvp_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||||
|
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("s-tiebreak-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Badge Sempre in palestra end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/serie-allenamenti-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `serieConsecutiva()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
|
||||||
|
* mano. Qui invece si scrivono allenamenti e risposte veri su `eventi_app`/
|
||||||
|
* `risposte_presenze`, si rileggono via REST con la stessa forma di `fetchPresenze()`/
|
||||||
|
* `daRiga()`, e si passa il risultato attraverso `serieConsecutiva()` fino a `statoBadge()` sul
|
||||||
|
* badge `serie-allenamenti`: se una colonna cambia nome o la mappatura 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:
|
||||||
|
* - un buco nella serie (assente o nessuna risposta) la azzera;
|
||||||
|
* - un infortunio invece la **congela** (non la azzera): l'allenamento saltato per infortunio
|
||||||
|
* non conta né a favore né contro, e la serie riparte da dove si era fermata.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-serie-allenamenti-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
|
import { serieConsecutiva } 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 Sempre in palestra sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("serie-allenamenti-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Sempre in palestra su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-serie-allenamenti-badge";
|
||||||
|
const serieDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!;
|
||||||
|
// "Oggi" fissato nel futuro: gli allenamenti 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function creaAllenamento(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: `Allenamento ${id}`,
|
||||||
|
data: dataEvento(i),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(eventoId: string, giocatoreId: string, stato: 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 }),
|
||||||
|
});
|
||||||
|
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 leggiPresenze(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: string;
|
||||||
|
}>;
|
||||||
|
const mappa: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(serieAllenamenti: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
serieAllenamenti,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
presenze: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il badge passa da bronzo a oro con presenze consecutive vere, e un buco lo azzera",
|
||||||
|
async () => {
|
||||||
|
// 10 allenamenti passati, tutti presenti per "sa1": bronzo (3), poi argento (6), poi
|
||||||
|
// oro (10) man mano che si rileggono i dati.
|
||||||
|
for (let i = 1; i <= 10; i += 1) {
|
||||||
|
await creaAllenamento(`${PREFISSO}-a${String(i).padStart(2, "0")}`, i);
|
||||||
|
await rispondi(`${PREFISSO}-a${String(i).padStart(2, "0")}`, "sa1", "presente");
|
||||||
|
}
|
||||||
|
const eventi10 = await leggiEventi();
|
||||||
|
const presenze10 = await leggiPresenze();
|
||||||
|
const serieA3 = serieConsecutiva(
|
||||||
|
"sa1",
|
||||||
|
eventi10.slice(0, 3),
|
||||||
|
presenze10,
|
||||||
|
"allenamento",
|
||||||
|
OGGI,
|
||||||
|
);
|
||||||
|
assert.equal(serieA3, 3);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
|
||||||
|
"bronzo",
|
||||||
|
"3 allenamenti di fila: soglia bronzo",
|
||||||
|
);
|
||||||
|
|
||||||
|
const serieCompleta = serieConsecutiva("sa1", eventi10, presenze10, "allenamento", OGGI);
|
||||||
|
assert.equal(serieCompleta, 10, "presente a tutti e 10, nessun buco");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
|
||||||
|
"oro",
|
||||||
|
"10 allenamenti di fila: soglia oro",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un undicesimo allenamento con un buco (assente) azzera la serie.
|
||||||
|
await creaAllenamento(`${PREFISSO}-a11`, 11);
|
||||||
|
await rispondi(`${PREFISSO}-a11`, "sa1", "assente");
|
||||||
|
const eventiConBuco = await leggiEventi();
|
||||||
|
const presenzeConBuco = await leggiPresenze();
|
||||||
|
const serieDopoBuco = serieConsecutiva(
|
||||||
|
"sa1",
|
||||||
|
eventiConBuco,
|
||||||
|
presenzeConBuco,
|
||||||
|
"allenamento",
|
||||||
|
OGGI,
|
||||||
|
);
|
||||||
|
assert.equal(serieDopoBuco, 0, "un'assenza azzera la serie, anche dopo 10 di fila");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieDopoBuco)).grado,
|
||||||
|
null,
|
||||||
|
"serie azzerata: nessun grado, si riparte da zero",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("un infortunio congela la serie invece di azzerarla", async () => {
|
||||||
|
// "sa2": presente ai primi due allenamenti, poi infortunato al terzo (saltato, non
|
||||||
|
// conta né a favore né contro), poi di nuovo presente: la serie deve restare a 3, non
|
||||||
|
// ripartire da 1.
|
||||||
|
const eventi = [`${PREFISSO}-b01`, `${PREFISSO}-b02`, `${PREFISSO}-b03`, `${PREFISSO}-b04`];
|
||||||
|
for (let i = 0; i < eventi.length; i += 1) {
|
||||||
|
await creaAllenamento(eventi[i]!, 20 + i);
|
||||||
|
}
|
||||||
|
await rispondi(eventi[0]!, "sa2", "presente");
|
||||||
|
await rispondi(eventi[1]!, "sa2", "presente");
|
||||||
|
await rispondi(eventi[2]!, "sa2", "infortunato");
|
||||||
|
await rispondi(eventi[3]!, "sa2", "presente");
|
||||||
|
|
||||||
|
const eventiLetti = await leggiEventi();
|
||||||
|
const presenzeLette = await leggiPresenze();
|
||||||
|
const serie = serieConsecutiva("sa2", eventiLetti, presenzeLette, "allenamento", OGGI);
|
||||||
|
assert.equal(serie, 3, "l'infortunio è saltato: 3 presenze restano consecutive");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
|
||||||
|
"bronzo",
|
||||||
|
"3 di fila (infortunio escluso dal conteggio): soglia bronzo",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("serie-allenamenti-badge");
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
@@ -40,6 +40,27 @@ assert.equal(vincitoreCategoria(voti, "m1", "cuore"), null, "nessun voto, nessun
|
|||||||
const pari = [v("m3", "meme", "g1", "g2", "Bruno"), v("m3", "meme", "g2", "g5", "Anna")];
|
const pari = [v("m3", "meme", "g1", "g2", "Bruno"), v("m3", "meme", "g2", "g5", "Anna")];
|
||||||
assert.equal(vincitoreCategoria(pari, "m3", "meme"), null, "parità: nessun vincitore");
|
assert.equal(vincitoreCategoria(pari, "m3", "meme"), null, "parità: nessun vincitore");
|
||||||
|
|
||||||
|
// Un solo voto totale: vince comunque, non serve concorrenza per avere un vantaggio netto.
|
||||||
|
const votoSingolo = [v("m4", "cuore", "g1", "g9", "Zoe")];
|
||||||
|
assert.equal(
|
||||||
|
vincitoreCategoria(votoSingolo, "m4", "cuore")?.nome,
|
||||||
|
"Zoe",
|
||||||
|
"un voto solo basta se non c'è nessun altro candidato",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tre candidati: i primi due pari in testa, il terzo staccato. Il pareggio conta comunque,
|
||||||
|
// non basta che qualcun altro sia sotto per assegnare la categoria.
|
||||||
|
const triplaPari = [
|
||||||
|
v("m5", "spirito", "g1", "g8", "Uno"),
|
||||||
|
v("m5", "spirito", "g2", "g7", "Due"),
|
||||||
|
v("m5", "spirito", "g3", "g6", "Tre"),
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
vincitoreCategoria(triplaPari, "m5", "spirito"),
|
||||||
|
null,
|
||||||
|
"primo e secondo pari: nessun vincitore anche con un terzo staccato",
|
||||||
|
);
|
||||||
|
|
||||||
// --- mioVotoSocial -----------------------------------------------------------
|
// --- mioVotoSocial -----------------------------------------------------------
|
||||||
assert.equal(mioVotoSocial(voti, "m1", "affidabile", "g1")?.votato_id, "g2");
|
assert.equal(mioVotoSocial(voti, "m1", "affidabile", "g1")?.votato_id, "g2");
|
||||||
assert.equal(mioVotoSocial(voti, "m1", "meme", "g3"), null, "non ho votato questa categoria");
|
assert.equal(mioVotoSocial(voti, "m1", "meme", "g3"), null, "non ho votato questa categoria");
|
||||||
@@ -51,6 +72,19 @@ assert.deepEqual(badgeSocialVinti(voti, "g9"), {}, "chi non vince non ha badge")
|
|||||||
assert.deepEqual(badgeSocialVinti(pari, "g2"), {}, "una parità non assegna badge");
|
assert.deepEqual(badgeSocialVinti(pari, "g2"), {}, "una parità non assegna badge");
|
||||||
assert.deepEqual(badgeSocialVinti([], "g2"), {});
|
assert.deepEqual(badgeSocialVinti([], "g2"), {});
|
||||||
|
|
||||||
|
// Categorie e partite diverse non si mischiano: g2 vince "affidabile" in m1/m2 (già sopra) e
|
||||||
|
// "fairplay" in m2, un'altra categoria nella stessa partita — i due conteggi restano separati.
|
||||||
|
const conAltraCategoria: VotoSocial[] = [
|
||||||
|
...voti,
|
||||||
|
v("m2", "fairplay", "g3", "g2", "Bruno"),
|
||||||
|
v("m2", "fairplay", "g4", "g2", "Bruno"),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
badgeSocialVinti(conAltraCategoria, "g2"),
|
||||||
|
{ affidabile: 2, fairplay: 1 },
|
||||||
|
"vittorie in categorie diverse, anche nella stessa partita, si contano separate",
|
||||||
|
);
|
||||||
|
|
||||||
// --- invarianti sulle categorie ----------------------------------------------
|
// --- invarianti sulle categorie ----------------------------------------------
|
||||||
assert.equal(
|
assert.equal(
|
||||||
new Set(categorieSocial.map((c) => c.id)).size,
|
new Set(categorieSocial.map((c) => c.id)).size,
|
||||||
|
|||||||
+128
-2
@@ -25,6 +25,7 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
|||||||
presenze: 0,
|
presenze: 0,
|
||||||
mvp: 0,
|
mvp: 0,
|
||||||
mediaVoto: 0,
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
palloni: 0,
|
palloni: 0,
|
||||||
cacche: 0,
|
cacche: 0,
|
||||||
cacchePartita: 0,
|
cacchePartita: 0,
|
||||||
@@ -66,6 +67,95 @@ const pagella = badgeDefs.find((b) => b.id === "pagella")!;
|
|||||||
assert.equal(gradoRaggiunto(pagella, 6.4), null);
|
assert.equal(gradoRaggiunto(pagella, 6.4), null);
|
||||||
assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo");
|
assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo");
|
||||||
|
|
||||||
|
// Sotto i voti minimi il badge resta bloccato anche con una media altissima: un voto solo
|
||||||
|
// non deve poter sbloccare/sfilare Pagellone.
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).grado,
|
||||||
|
null,
|
||||||
|
"1 voto su 10: non basta, sotto la soglia minima",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 4 })).grado,
|
||||||
|
null,
|
||||||
|
"4 voti: ancora sotto la soglia minima (5)",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 5 })).grado,
|
||||||
|
"oro",
|
||||||
|
"5 voti: la soglia minima è raggiunta, la media conta",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 6.5, votiPagella: 5 })).grado,
|
||||||
|
"bronzo",
|
||||||
|
"sopra la soglia minima, valgono le normali soglie di grado",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confini argento/oro, non solo bronzo: stesso arrotondamento per difetto.
|
||||||
|
assert.equal(gradoRaggiunto(pagella, 7.4), "bronzo");
|
||||||
|
assert.equal(gradoRaggiunto(pagella, 7.5), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(pagella, 8.4), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(pagella, 8.5), "oro");
|
||||||
|
|
||||||
|
// Progresso con soglia decimale: valore/prossimaSoglia, non arrotondato per eccesso.
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 7, votiPagella: 5 })).progresso,
|
||||||
|
93,
|
||||||
|
"7/7.5 = 93.3%, arrotondato a 93",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sotto la soglia minima di voti il valore è forzato a 0: anche il progresso torna a 0%,
|
||||||
|
// non alla percentuale che la media reale avrebbe suggerito.
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).progresso,
|
||||||
|
0,
|
||||||
|
"valore azzerato dal gate: progresso azzerato anch'esso, non ingannevole",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- palloni: soglie 3 / 6 / 10 -----------------------------------------------
|
||||||
|
const palloniDef = badgeDefs.find((b) => b.id === "palloni")!;
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 2), null, "sotto la prima soglia nessun grado");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 3), "bronzo", "la soglia è inclusiva");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 5), "bronzo");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 6), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 9), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 10), "oro");
|
||||||
|
assert.equal(gradoRaggiunto(palloniDef, 99), "oro", "oltre l'oro resta oro");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(palloniDef, g({ palloni: 5 })).progresso,
|
||||||
|
83,
|
||||||
|
"5 turni su 6 per l'argento: 83%",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- presenze: soglie 5 / 15 / 30 ---------------------------------------------
|
||||||
|
const presenzeDef = badgeDefs.find((b) => b.id === "presenze")!;
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 4), null, "sotto la prima soglia nessun grado");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 5), "bronzo", "la soglia è inclusiva");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 14), "bronzo");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 15), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 29), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 30), "oro");
|
||||||
|
assert.equal(gradoRaggiunto(presenzeDef, 999), "oro", "oltre l'oro resta oro");
|
||||||
|
|
||||||
|
// --- serie-allenamenti: soglie 3 / 6 / 10 -------------------------------------
|
||||||
|
const serieAllenamentiDef = badgeDefs.find((b) => b.id === "serie-allenamenti")!;
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 2), null, "sotto la prima soglia nessun grado");
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 3), "bronzo", "la soglia è inclusiva");
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 5), "bronzo");
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 6), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "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(
|
||||||
@@ -78,12 +168,22 @@ const nessunSegreto = g({ mvp: 2, mediaVoto: 7.9 });
|
|||||||
assert.equal(badgeSegretiSbloccati(nessunSegreto).length, 0, "serve media 8, non 7.9");
|
assert.equal(badgeSegretiSbloccati(nessunSegreto).length, 0, "serve media 8, non 7.9");
|
||||||
assert.equal(segretiNascosti(nessunSegreto), badgeSegreti.length);
|
assert.equal(segretiNascosti(nessunSegreto), badgeSegreti.length);
|
||||||
|
|
||||||
const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8 }));
|
const tiebreak = badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8, votiPagella: 5 }));
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tiebreak.map((b) => b.def.id),
|
tiebreak.map((b) => b.def.id),
|
||||||
["s-tiebreak"],
|
["s-tiebreak"],
|
||||||
"sblocca solo il segreto il cui requisito è soddisfatto",
|
"sblocca solo il segreto il cui requisito è soddisfatto",
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
badgeSegretiSbloccati(g({ mvp: 2, mediaVoto: 8, votiPagella: 4 })).length,
|
||||||
|
0,
|
||||||
|
"come Pagellone: sotto la soglia minima di voti la media non conta, nemmeno qui",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
badgeSegretiSbloccati(g({ mvp: 1, mediaVoto: 8, votiPagella: 5 })).length,
|
||||||
|
0,
|
||||||
|
"un solo MVP non basta",
|
||||||
|
);
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
badgeSegretiSbloccati(g({ infortuni: 3 })).map((b) => b.def.id),
|
badgeSegretiSbloccati(g({ infortuni: 3 })).map((b) => b.def.id),
|
||||||
@@ -94,10 +194,12 @@ assert.deepEqual(
|
|||||||
badgeSegretiSbloccati(g({ ritardi: 5 })).map((b) => b.def.id),
|
badgeSegretiSbloccati(g({ ritardi: 5 })).map((b) => b.def.id),
|
||||||
["s-ritardi"],
|
["s-ritardi"],
|
||||||
);
|
);
|
||||||
|
assert.equal(badgeSegretiSbloccati(g({ ritardi: 4 })).length, 0, "4 ritardi non bastano");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
badgeSegretiSbloccati(g({ cacche: 3 })).map((b) => b.def.id),
|
badgeSegretiSbloccati(g({ cacche: 3 })).map((b) => b.def.id),
|
||||||
["s-cacche"],
|
["s-cacche"],
|
||||||
);
|
);
|
||||||
|
assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
|
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
|
||||||
["s-mai-forfait"],
|
["s-mai-forfait"],
|
||||||
@@ -107,6 +209,27 @@ assert.equal(
|
|||||||
0,
|
0,
|
||||||
"servono entrambe le condizioni",
|
"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());
|
||||||
@@ -116,7 +239,9 @@ assert.equal(vuota.sbloccati.length, 0);
|
|||||||
assert.equal(vuota.inProgresso.length, badgeDefs.length);
|
assert.equal(vuota.inProgresso.length, badgeDefs.length);
|
||||||
assert.equal(vuota.nascosti, badgeSegreti.length);
|
assert.equal(vuota.nascosti, badgeSegreti.length);
|
||||||
|
|
||||||
const piena = collezioneBadge(g({ mvp: 5, mediaVoto: 8, presenze: 30, infortuni: 3 }));
|
const piena = collezioneBadge(
|
||||||
|
g({ mvp: 5, mediaVoto: 8, votiPagella: 5, presenze: 30, infortuni: 3 }),
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
piena.ottenuti,
|
piena.ottenuti,
|
||||||
piena.sbloccati.length + piena.segreti.length,
|
piena.sbloccati.length + piena.segreti.length,
|
||||||
@@ -133,6 +258,7 @@ assert.equal(
|
|||||||
g({
|
g({
|
||||||
mvp: 5,
|
mvp: 5,
|
||||||
mediaVoto: 10,
|
mediaVoto: 10,
|
||||||
|
votiPagella: 5,
|
||||||
palloni: 10,
|
palloni: 10,
|
||||||
presenze: 30,
|
presenze: 30,
|
||||||
serieAllenamenti: 10,
|
serieAllenamenti: 10,
|
||||||
|
|||||||
+261
-3
@@ -4,12 +4,21 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
|
CSI_COPPA_PROJECT_ID,
|
||||||
isNostraSquadra,
|
isNostraSquadra,
|
||||||
|
matchDaPartitaCsi,
|
||||||
parseClassifica,
|
parseClassifica,
|
||||||
|
parseFormazioni,
|
||||||
|
parseInfoPartita,
|
||||||
|
parsePrecedenti,
|
||||||
partiteDaEventi,
|
partiteDaEventi,
|
||||||
|
partiteFormatoSospetto,
|
||||||
partiteGiocate,
|
partiteGiocate,
|
||||||
urlClassifica,
|
urlClassifica,
|
||||||
urlPartite,
|
urlPartite,
|
||||||
|
urlPartitaFormazioni,
|
||||||
|
urlPartitaInfo,
|
||||||
|
urlPartitaPrecedenti,
|
||||||
} from "@/lib/csi-core";
|
} from "@/lib/csi-core";
|
||||||
|
|
||||||
const htmlClassifica = `
|
const htmlClassifica = `
|
||||||
@@ -47,6 +56,20 @@ assert.deepEqual(righe[1], {
|
|||||||
assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML");
|
assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML");
|
||||||
assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash");
|
assert.deepEqual(parseClassifica("<p>pagina cambiata</p>"), [], "portale cambiato: nessun crash");
|
||||||
|
|
||||||
|
// La Coppa ha un girone più piccolo (poche squadre) ma la stessa struttura di tabella del
|
||||||
|
// campionato: `parseClassifica()` funziona invariata, non serve un parser dedicato.
|
||||||
|
const htmlCoppa = `
|
||||||
|
<table class="ranking">
|
||||||
|
<tr><th>Pos</th><th>Squadra</th></tr>
|
||||||
|
<tr><td>1</td><td><a href="team_details.php?team_id=2">Set in the City</a></td>
|
||||||
|
<td><b>7</b></td><td>3</td><td>2</td><td>1</td><td>0</td><td>0</td><td>8</td><td>4</td></tr>
|
||||||
|
<tr><td>2</td><td><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></td>
|
||||||
|
<td><b>6</b></td><td>3</td><td>2</td><td>1</td><td>0</td><td>0</td><td>8</td><td>5</td></tr>
|
||||||
|
</table>`;
|
||||||
|
const righeCoppa = parseClassifica(htmlCoppa);
|
||||||
|
assert.equal(righeCoppa.length, 2, "girone di Coppa più piccolo del campionato: ok comunque");
|
||||||
|
assert.equal(righeCoppa[1]!.squadra, "C.R.A.P. Volley");
|
||||||
|
|
||||||
assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley "));
|
assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley "));
|
||||||
assert.ok(!isNostraSquadra("CRAP Volley B"));
|
assert.ok(!isNostraSquadra("CRAP Volley B"));
|
||||||
|
|
||||||
@@ -55,11 +78,17 @@ const eventi = [
|
|||||||
id: 1,
|
id: 1,
|
||||||
start: "2025-11-12T22:00:00",
|
start: "2025-11-12T22:00:00",
|
||||||
team1: "C.R.A.P. Volley",
|
team1: "C.R.A.P. Volley",
|
||||||
|
team1_logo: "images/teams/logos/CRAP_Volley_3359.jpg",
|
||||||
team2: "AMCM",
|
team2: "AMCM",
|
||||||
|
team2_logo: "images/teams/logos/AMCM_1943.JPG",
|
||||||
result: "3 - 1",
|
result: "3 - 1",
|
||||||
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
|
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
|
||||||
field: "Sabin Piccolo",
|
field: "Sabin Piccolo",
|
||||||
project: "PVM - Campionato Open Misto Eccellenza",
|
project: "PVM - Campionato Open Misto Eccellenza",
|
||||||
|
group: "Girone B",
|
||||||
|
match_number: "5/XEB",
|
||||||
|
referees: "",
|
||||||
|
link: "https://livescore.csibologna.it/match_details.php?id=1",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -84,6 +113,14 @@ const casa = partite.find((p) => p.id === "1")!;
|
|||||||
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
|
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
|
||||||
assert.deepEqual(casa.parziali[1], [23, 25]);
|
assert.deepEqual(casa.parziali[1], [23, 25]);
|
||||||
assert.equal(casa.ora, "22:00");
|
assert.equal(casa.ora, "22:00");
|
||||||
|
assert.equal(
|
||||||
|
casa.logoAvversario,
|
||||||
|
"images/teams/logos/AMCM_1943.JPG",
|
||||||
|
"logo dell'avversario, non il nostro",
|
||||||
|
);
|
||||||
|
assert.equal(casa.girone, "Girone B");
|
||||||
|
assert.equal(casa.numeroGara, "5/XEB");
|
||||||
|
assert.equal(casa.link, "https://livescore.csibologna.it/match_details.php?id=1");
|
||||||
|
|
||||||
const trasferta = partite.find((p) => p.id === "2")!;
|
const trasferta = partite.find((p) => p.id === "2")!;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -96,18 +133,239 @@ assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali");
|
|||||||
assert.equal(partiteGiocate(partite).length, 2);
|
assert.equal(partiteGiocate(partite).length, 2);
|
||||||
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
|
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
|
||||||
|
|
||||||
|
// --- matchDaPartitaCsi: i campi leggeri (logo, girone, n° gara, arbitro, link) arrivano
|
||||||
|
// fino alla forma usata nelle liste risultati, non solo nel tipo interno --------------------
|
||||||
|
assert.deepEqual(matchDaPartitaCsi(casa), {
|
||||||
|
id: "1",
|
||||||
|
data: "2025-11-12",
|
||||||
|
avversario: "AMCM",
|
||||||
|
logoAvversario: "images/teams/logos/AMCM_1943.JPG",
|
||||||
|
casa: true,
|
||||||
|
setNostri: 3,
|
||||||
|
setLoro: 1,
|
||||||
|
parziali: casa.parziali,
|
||||||
|
campo: "Sabin Piccolo",
|
||||||
|
girone: "Girone B",
|
||||||
|
numeroGara: "5/XEB",
|
||||||
|
arbitro: "",
|
||||||
|
link: "https://livescore.csibologna.it/match_details.php?id=1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- partiteFormatoSospetto: distingue "nessuna gara ancora" da "formato rotto" -------------
|
||||||
|
assert.equal(
|
||||||
|
partiteFormatoSospetto(eventi, partite),
|
||||||
|
false,
|
||||||
|
"eventi grezzi presenti e riconosciuti: nessun sospetto",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
partiteFormatoSospetto([], []),
|
||||||
|
false,
|
||||||
|
"nessun evento grezzo: probabilmente solo una stagione senza gare, non un formato rotto",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
partiteFormatoSospetto("non è un array", []),
|
||||||
|
true,
|
||||||
|
"la risposta non è più un array: il formato è cambiato",
|
||||||
|
);
|
||||||
|
const eventiSenzaCampiRiconosciuti = [
|
||||||
|
{ id: 1, quando: "2025-11-12", squadraCasa: "C.R.A.P. Volley", squadraOspite: "AMCM" },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
partiteFormatoSospetto(
|
||||||
|
eventiSenzaCampiRiconosciuti,
|
||||||
|
partiteDaEventi(eventiSenzaCampiRiconosciuti),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
"eventi presenti ma con campi rinominati: nessuno riconosciuto, sospetto fondato",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- parseInfoPartita: giornata e nota libera da match-main.php -----------------------------
|
||||||
|
const htmlMatchMain = `
|
||||||
|
<div>
|
||||||
|
<small><small class="d-block">2<sup>a</sup> Giornata</small></small>
|
||||||
|
<div class="pt-3 border-top text-center">
|
||||||
|
Impianto: <a href="field_details.php?id=400">Sabin Piccolo</a>
|
||||||
|
<div class="text-start"><span>Pubblico non ammesso</span></div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
assert.deepEqual(parseInfoPartita(htmlMatchMain), {
|
||||||
|
giornata: "2ª Giornata",
|
||||||
|
nota: "Pubblico non ammesso",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseInfoPartita("<p>pagina cambiata</p>"),
|
||||||
|
{ giornata: "", nota: "" },
|
||||||
|
"portale cambiato: nessun crash, campi vuoti",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- parseFormazioni: titolari/panchina/staff di entrambe le squadre, noi riconosciuti
|
||||||
|
// tramite isNostraSquadra() e non un ordine fisso ------------------------------------------
|
||||||
|
const htmlFormazioni = `
|
||||||
|
<!-- SQUADRA CASA -->
|
||||||
|
<div class="col-12 col-md-6 mt-4">
|
||||||
|
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
|
||||||
|
<ul class="list-group mt-2">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="fa-layers-text" data-fa-transform="shrink-8">73</span>
|
||||||
|
<strong><a href="person_details.php?id=1">Bologna Mattias</a></strong>
|
||||||
|
<div class="small"></div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="fa-layers-text" data-fa-transform="shrink-8">11</span>
|
||||||
|
<strong><a href="person_details.php?id=2">Chilese Silvia</a></strong>
|
||||||
|
<div class="small">Libero</div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item bg-light"><strong>A DISPOSIZIONE</strong></li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="fa-layers-text" data-fa-transform="shrink-8">14</span>
|
||||||
|
<strong><a href="person_details.php?id=3">Di Castelnuovo Carlo</a></strong>
|
||||||
|
<div class="small"></div>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item bg-light"><strong>STAFF</strong></li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<strong><a href="person_details.php?id=4">Ricci Iacopo</a></strong>
|
||||||
|
<div class="small">Allenatore</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- SQUADRA OSPITE -->
|
||||||
|
<div class="col-12 col-md-6 mt-4">
|
||||||
|
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
|
||||||
|
<ul class="list-group mt-2">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="fa-layers-text" data-fa-transform="shrink-8">7</span>
|
||||||
|
<strong><a href="person_details.php?id=5">Rossi Anna</a></strong>
|
||||||
|
<div class="small"></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
const formazioni = parseFormazioni(htmlFormazioni)!;
|
||||||
|
assert.equal(formazioni.noi.squadra, "C.R.A.P. Volley");
|
||||||
|
assert.equal(
|
||||||
|
formazioni.avversario.squadra,
|
||||||
|
"AMCM",
|
||||||
|
"riconosciuta come avversario anche da seconda posizione",
|
||||||
|
);
|
||||||
|
assert.deepEqual(formazioni.noi.titolari, [
|
||||||
|
{ numero: "73", nome: "Bologna Mattias", ruolo: "" },
|
||||||
|
{ numero: "11", nome: "Chilese Silvia", ruolo: "Libero" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(formazioni.noi.panchina, [
|
||||||
|
{ numero: "14", nome: "Di Castelnuovo Carlo", ruolo: "" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(formazioni.noi.staff, [{ nome: "Ricci Iacopo", ruolo: "Allenatore" }]);
|
||||||
|
assert.equal(formazioni.avversario.titolari.length, 1);
|
||||||
|
assert.deepEqual(parseFormazioni("<p>pagina cambiata</p>"), null, "portale cambiato: nessun crash");
|
||||||
|
assert.deepEqual(
|
||||||
|
parseFormazioni(htmlFormazioni.replace("C.R.A.P. Volley", "Un'altra squadra")),
|
||||||
|
null,
|
||||||
|
"nessuna delle due è la nostra squadra: formato non riconosciuto",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- parsePrecedenti: storico scontri diretti e probabilità di vittoria da match-stats.php --
|
||||||
|
const htmlPrecedenti = `
|
||||||
|
<div class="col-12 col-md-6 my-3">Squadra casa
|
||||||
|
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 my-3">Squadra ospite
|
||||||
|
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
|
||||||
|
</div>
|
||||||
|
<div><span class="text-muted">Precedenti:</span> <b><a href="#">5</a></b></div>
|
||||||
|
<div class="d-flex justify-content-between pt-2 px-5">
|
||||||
|
<div><b>3</b></div>
|
||||||
|
<div class="">vittorie</div>
|
||||||
|
<div><b>2</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between small text-muted px-5">
|
||||||
|
<div><b>1</b></div>
|
||||||
|
<div>in casa</div>
|
||||||
|
<div><b>1</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between small text-muted px-5">
|
||||||
|
<div><b>2</b></div>
|
||||||
|
<div>fuori</div>
|
||||||
|
<div><b>1</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar bg-success" style="width: 56.97%;">56.97%</div>
|
||||||
|
<div class="progress-bar bg-danger" style="width: 43.03%;">43.03%</div>`;
|
||||||
|
assert.deepEqual(parsePrecedenti(htmlPrecedenti), {
|
||||||
|
totale: 5,
|
||||||
|
vinteNoi: 3,
|
||||||
|
vinteAvversario: 2,
|
||||||
|
casaNoi: 1,
|
||||||
|
casaAvversario: 1,
|
||||||
|
fuoriNoi: 2,
|
||||||
|
fuoriAvversario: 1,
|
||||||
|
probabilitaNoi: 56.97,
|
||||||
|
probabilitaAvversario: 43.03,
|
||||||
|
});
|
||||||
|
// Se siamo la squadra ospite invece che casa, i numeri vanno scambiati di conseguenza.
|
||||||
|
const precedentiOspiti = parsePrecedenti(
|
||||||
|
htmlPrecedenti
|
||||||
|
.replace("C.R.A.P. Volley", "__TMP__")
|
||||||
|
.replace("AMCM", "C.R.A.P. Volley")
|
||||||
|
.replace("__TMP__", "AMCM"),
|
||||||
|
)!;
|
||||||
|
assert.deepEqual(
|
||||||
|
[precedentiOspiti.vinteNoi, precedentiOspiti.vinteAvversario, precedentiOspiti.probabilitaNoi],
|
||||||
|
[2, 3, 43.03],
|
||||||
|
"noi/avversario scambiati quando siamo la squadra ospite",
|
||||||
|
);
|
||||||
|
assert.equal(parsePrecedenti("<p>pagina cambiata</p>"), null, "portale cambiato: nessun crash");
|
||||||
|
|
||||||
|
// --- URL dei nuovi endpoint per-partita ------------------------------------------------------
|
||||||
|
assert.equal(
|
||||||
|
urlPartitaInfo("33694"),
|
||||||
|
"https://livescore.csibologna.it/components/match-main.php?match_id=33694",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
urlPartitaFormazioni("33694"),
|
||||||
|
"https://livescore.csibologna.it/components/match-players.php?match_id=33694",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
urlPartitaPrecedenti("33694"),
|
||||||
|
"https://livescore.csibologna.it/components/match-stats.php?match_id=33694",
|
||||||
|
);
|
||||||
|
|
||||||
if (process.env["CSI_LIVE"]) {
|
if (process.env["CSI_LIVE"]) {
|
||||||
const [html, json] = await Promise.all([
|
const [html, htmlCoppa, json] = await Promise.all([
|
||||||
fetch(urlClassifica()).then((r) => r.text()),
|
fetch(urlClassifica()).then((r) => r.text()),
|
||||||
|
fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()),
|
||||||
fetch(urlPartite()).then((r) => r.json()),
|
fetch(urlPartite()).then((r) => r.json()),
|
||||||
]);
|
]);
|
||||||
const live = parseClassifica(html);
|
const live = parseClassifica(html);
|
||||||
const nostra = live.find((r) => isNostraSquadra(r.squadra));
|
const nostra = live.find((r) => isNostraSquadra(r.squadra));
|
||||||
assert.ok(nostra, "la nostra squadra è in classifica");
|
assert.ok(nostra, "la nostra squadra è in classifica");
|
||||||
assert.ok(live.length >= 8 && nostra.giocate > 0);
|
assert.ok(live.length >= 8 && nostra.giocate > 0);
|
||||||
|
const liveCoppa = parseClassifica(htmlCoppa);
|
||||||
|
assert.ok(
|
||||||
|
liveCoppa.some((r) => isNostraSquadra(r.squadra)),
|
||||||
|
"la nostra squadra è nel girone di Coppa",
|
||||||
|
);
|
||||||
const livePartite = partiteDaEventi(json);
|
const livePartite = partiteDaEventi(json);
|
||||||
assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0);
|
const liveGiocate = partiteGiocate(livePartite);
|
||||||
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`);
|
assert.ok(livePartite.length > 0 && liveGiocate.length > 0);
|
||||||
|
assert.ok(
|
||||||
|
liveGiocate.every((p) => p.link.startsWith("https://")),
|
||||||
|
"ogni partita ha un referto",
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaGiocata = liveGiocate[0]!;
|
||||||
|
const [liveMain, livePlayers, liveStats] = await Promise.all([
|
||||||
|
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
|
||||||
|
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
|
||||||
|
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
|
||||||
|
]);
|
||||||
|
const liveFormazioni = parseFormazioni(livePlayers);
|
||||||
|
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
|
||||||
|
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
|
||||||
|
const livePrecedenti = parsePrecedenti(liveStats);
|
||||||
|
assert.ok(livePrecedenti, "precedenti riconosciuti");
|
||||||
|
console.log(
|
||||||
|
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
|
||||||
|
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
|
||||||
|
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("csi-core: ok");
|
console.log("csi-core: ok");
|
||||||
|
|||||||
@@ -99,4 +99,14 @@ assert.equal(
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Uno stesso giocatore infortunato in un evento e in ritardo in un altro: i due conteggi
|
||||||
|
// restano indipendenti, nessuno "ruba" all'altro.
|
||||||
|
const misto: MappaPresenze = {
|
||||||
|
e1: { g4: "infortunato" },
|
||||||
|
e2: { g4: "ritardo" },
|
||||||
|
e3: { g4: "infortunato" },
|
||||||
|
};
|
||||||
|
assert.deepEqual(contaInfortuni(misto, eventi, OGGI), { g4: 2 });
|
||||||
|
assert.deepEqual(contaRitardi(misto, eventi, OGGI), { g4: 1 });
|
||||||
|
|
||||||
console.log("infortuni: ok");
|
console.log("infortuni: ok");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
mvpVintiPerGiocatore,
|
mvpVintiPerGiocatore,
|
||||||
vincitoriMvp,
|
vincitoriMvp,
|
||||||
votoMvpAperto,
|
votoMvpAperto,
|
||||||
|
VOTI_MINIMI_MVP,
|
||||||
type VotoMvp,
|
type VotoMvp,
|
||||||
} from "@/lib/mvp-voti";
|
} from "@/lib/mvp-voti";
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ const partita = [
|
|||||||
v("m1", "g3", "g2", "Bruno"),
|
v("m1", "g3", "g2", "Bruno"),
|
||||||
v("m1", "g4", "g5", "Anna"),
|
v("m1", "g4", "g5", "Anna"),
|
||||||
v("m2", "g1", "g5", "Anna"),
|
v("m2", "g1", "g5", "Anna"),
|
||||||
|
v("m2", "g3", "g5", "Anna"),
|
||||||
];
|
];
|
||||||
|
|
||||||
assert.deepEqual(conteggioPartita(partita, "m1"), [
|
assert.deepEqual(conteggioPartita(partita, "m1"), [
|
||||||
@@ -46,10 +48,32 @@ assert.deepEqual(
|
|||||||
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
|
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
|
||||||
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato");
|
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato");
|
||||||
assert.deepEqual(vincitoriMvp([]), {});
|
assert.deepEqual(vincitoriMvp([]), {});
|
||||||
|
|
||||||
|
// --- vincitoriMvp: quorum minimo di voti -------------------------------------
|
||||||
|
assert.equal(VOTI_MINIMI_MVP, 2, "un solo voto non deve mai bastare da solo");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
vincitoriMvp([v("m4", "g1", "g2", "Solo")]),
|
vincitoriMvp([v("m4", "g1", "g2", "Solo")]),
|
||||||
|
{},
|
||||||
|
"un solo votante, senza concorrenza: sotto quorum, nessun MVP",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
vincitoriMvp([v("m4", "g1", "g2", "Solo"), v("m4", "g3", "g2", "Solo")]),
|
||||||
{ m4: "Solo" },
|
{ m4: "Solo" },
|
||||||
"un solo votante basta se non c'è concorrenza",
|
"due voti allo stesso candidato: quorum raggiunto",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tre candidati: i primi due pari in testa, un terzo staccato. Deve restare senza MVP,
|
||||||
|
// non basta che il terzo sia sotto: conta solo il confronto fra il primo e il secondo.
|
||||||
|
const triplaPari = [
|
||||||
|
v("m6", "g1", "g9", "Zeno"),
|
||||||
|
v("m6", "g2", "g8", "Anna"),
|
||||||
|
v("m6", "g3", "g7", "Bea"),
|
||||||
|
];
|
||||||
|
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie");
|
||||||
|
assert.deepEqual(
|
||||||
|
mvpVintiPerGiocatore(triplaPari),
|
||||||
|
{},
|
||||||
|
"stessa parità: nessuna vittoria netta da contare",
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- mioVoto -----------------------------------------------------------------
|
// --- mioVoto -----------------------------------------------------------------
|
||||||
@@ -69,10 +93,15 @@ assert.deepEqual(
|
|||||||
"la parità non assegna",
|
"la parità non assegna",
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]),
|
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
|
||||||
{ g2: 2, g5: 1 },
|
{ g2: 2, g5: 1 },
|
||||||
"i titoli si sommano su partite diverse",
|
"i titoli si sommano su partite diverse",
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]),
|
||||||
|
{ g2: 1, g5: 1 },
|
||||||
|
"m5 ha un solo voto: sotto quorum, non conta",
|
||||||
|
);
|
||||||
assert.deepEqual(mvpVintiPerGiocatore([]), {});
|
assert.deepEqual(mvpVintiPerGiocatore([]), {});
|
||||||
|
|
||||||
// --- votoMvpAperto: due ore dopo il fischio d'inizio -------------------------
|
// --- votoMvpAperto: due ore dopo il fischio d'inizio -------------------------
|
||||||
|
|||||||
+280
-7
@@ -30,11 +30,16 @@ const evento = (id: string, data: string, tipo: Evento["tipo"]): Evento => ({
|
|||||||
|
|
||||||
const trova = (lista: ObiettivoSquadra[], id: string) => lista.find((o) => o.id === id)!;
|
const trova = (lista: ObiettivoSquadra[], id: string) => lista.find((o) => o.id === id)!;
|
||||||
|
|
||||||
|
// Data di riferimento fissa: o1 (presenze del mese) ora dipende dal mese corrente,
|
||||||
|
// quindi va iniettata esplicitamente per avere test deterministici.
|
||||||
|
const OGGI_AGOSTO = new Date("2026-08-15T10:00:00Z");
|
||||||
|
|
||||||
// --- contesto vuoto: nessuna divisione per zero ------------------------------
|
// --- contesto vuoto: nessuna divisione per zero ------------------------------
|
||||||
const vuoti = obiettiviSquadra(giocatori, contestoVuoto);
|
const vuoti = obiettiviSquadra(giocatori, contestoVuoto, OGGI_AGOSTO);
|
||||||
assert.equal(trova(vuoti, "o1").valore, 0, "nessun evento nel mese: 0%, non NaN");
|
assert.equal(trova(vuoti, "o1").valore, 0, "nessun evento nel mese: 0%, non NaN");
|
||||||
assert.equal(trova(vuoti, "o2").valore, 0);
|
assert.equal(trova(vuoti, "o2").valore, 0);
|
||||||
assert.equal(trova(vuoti, "o12").valore, 0, "nessuna pagella: media 0");
|
assert.equal(trova(vuoti, "o12").valore, 0, "nessuna pagella: media 0");
|
||||||
|
assert.equal(trova(vuoti, "o13").valore, 0, "nessuna pagella: conteggio 0");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
vuoti.every((o) => Number.isFinite(o.valore)),
|
vuoti.every((o) => Number.isFinite(o.valore)),
|
||||||
"nessun valore NaN o infinito",
|
"nessun valore NaN o infinito",
|
||||||
@@ -49,25 +54,32 @@ const ctx: ContestoObiettivi = {
|
|||||||
presenze: tuttiPresenti,
|
presenze: tuttiPresenti,
|
||||||
pagelle: [],
|
pagelle: [],
|
||||||
};
|
};
|
||||||
assert.equal(trova(obiettiviSquadra(giocatori, ctx), "o1").valore, 100, "rosa al completo = 100%");
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, ctx, OGGI_AGOSTO), "o1").valore,
|
||||||
|
100,
|
||||||
|
"rosa al completo = 100%",
|
||||||
|
);
|
||||||
|
|
||||||
const metaRosa: MappaPresenze = {
|
const metaRosa: MappaPresenze = {
|
||||||
a1: Object.fromEntries(
|
a1: Object.fromEntries(
|
||||||
giocatori.map((g, i) => [g.id, i % 2 === 0 ? ("presente" as const) : ("assente" as const)]),
|
giocatori.map((g, i) => [g.id, i % 2 === 0 ? ("presente" as const) : ("assente" as const)]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const percentuale = trova(obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }), "o1").valore;
|
const percentuale = trova(
|
||||||
|
obiettiviSquadra(giocatori, { ...ctx, presenze: metaRosa }, OGGI_AGOSTO),
|
||||||
|
"o1",
|
||||||
|
).valore;
|
||||||
assert.ok(percentuale > 40 && percentuale < 60, `metà rosa presente ≈ 50%, era ${percentuale}`);
|
assert.ok(percentuale > 40 && percentuale < 60, `metà rosa presente ≈ 50%, era ${percentuale}`);
|
||||||
|
|
||||||
// Il ritardo conta come presenza, il "forse" no.
|
// Il ritardo conta come presenza, il "forse" no.
|
||||||
const conRitardo: MappaPresenze = { a1: { g1: "ritardo", g2: "forse" } };
|
const conRitardo: MappaPresenze = { a1: { g1: "ritardo", g2: "forse" } };
|
||||||
assert.equal(
|
assert.equal(
|
||||||
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o1").valore,
|
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }, OGGI_AGOSTO), "o1").valore,
|
||||||
Math.round((1 / giocatori.length) * 100),
|
Math.round((1 / giocatori.length) * 100),
|
||||||
"solo il ritardo conta come presente",
|
"solo il ritardo conta come presente",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }), "o2").valore,
|
trova(obiettiviSquadra(giocatori, { ...ctx, presenze: conRitardo }, OGGI_AGOSTO), "o2").valore,
|
||||||
Math.round((2 / giocatori.length) * 100),
|
Math.round((2 / giocatori.length) * 100),
|
||||||
"per le risposte anche il forse conta",
|
"per le risposte anche il forse conta",
|
||||||
);
|
);
|
||||||
@@ -78,7 +90,7 @@ const fuoriMese: ContestoObiettivi = {
|
|||||||
presenze: { s1: { g1: "presente" } },
|
presenze: { s1: { g1: "presente" } },
|
||||||
pagelle: [],
|
pagelle: [],
|
||||||
};
|
};
|
||||||
assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese), "o1").valore, 0);
|
assert.equal(trova(obiettiviSquadra(giocatori, fuoriMese, OGGI_AGOSTO), "o1").valore, 0);
|
||||||
|
|
||||||
// I compleanni non richiedono risposta.
|
// I compleanni non richiedono risposta.
|
||||||
const soloCompleanni: ContestoObiettivi = {
|
const soloCompleanni: ContestoObiettivi = {
|
||||||
@@ -86,7 +98,152 @@ const soloCompleanni: ContestoObiettivi = {
|
|||||||
presenze: {},
|
presenze: {},
|
||||||
pagelle: [],
|
pagelle: [],
|
||||||
};
|
};
|
||||||
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni), "o2").valore, 0);
|
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0);
|
||||||
|
|
||||||
|
// --- o1: si azzera a ogni cambio mese, in base agli eventi a calendario ------
|
||||||
|
{
|
||||||
|
// Stesso evento/presenze: "in mese" a settembre, "fuori mese" se letto da agosto.
|
||||||
|
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
|
||||||
|
const eventoSettembre: ContestoObiettivi = {
|
||||||
|
eventi: [evento("s2", "2026-09-04", "allenamento")],
|
||||||
|
presenze: { s2: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])) },
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, eventoSettembre, OGGI_SETTEMBRE), "o1").valore,
|
||||||
|
100,
|
||||||
|
"evento di settembre conta se oggi è settembre",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, eventoSettembre, OGGI_AGOSTO), "o1").valore,
|
||||||
|
0,
|
||||||
|
"lo stesso evento non conta se oggi è agosto",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Titolo e scadenza seguono il mese corrente, non più una stagione fissa.
|
||||||
|
const o1Agosto = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_AGOSTO), "o1");
|
||||||
|
assert.equal(o1Agosto.titolo, "90% di presenze ad agosto", "elisione 'ad' davanti a vocale");
|
||||||
|
assert.equal(o1Agosto.scadenza, "2026-08-31", "scadenza = ultimo giorno del mese");
|
||||||
|
|
||||||
|
const o1Settembre = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_SETTEMBRE), "o1");
|
||||||
|
assert.ok(
|
||||||
|
o1Settembre.titolo.includes("settembre"),
|
||||||
|
"titolo o1 riflette il mese iniettato (settembre)",
|
||||||
|
);
|
||||||
|
assert.equal(o1Settembre.scadenza, "2026-09-30", "scadenza = ultimo giorno di settembre (30 gg)");
|
||||||
|
|
||||||
|
// La scadenza di o2 ("Tutti rispondono alle convocazioni") era una data fissa
|
||||||
|
// ("2026-09-30"): ora segue lo stesso mese dinamico di o1.
|
||||||
|
const o2Agosto = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_AGOSTO), "o2");
|
||||||
|
assert.equal(o2Agosto.scadenza, "2026-08-31", "scadenza o2 = ultimo giorno del mese iniettato");
|
||||||
|
const o2Settembre = trova(obiettiviSquadra(giocatori, contestoVuoto, OGGI_SETTEMBRE), "o2");
|
||||||
|
assert.equal(o2Settembre.scadenza, "2026-09-30", "scadenza o2 cambia con il mese iniettato");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- o1/o2: rosa vuota, filtro sui tipi di evento, aggregazione su più eventi ---
|
||||||
|
{
|
||||||
|
const evetoAgostoSingolo: ContestoObiettivi = {
|
||||||
|
eventi: [evento("rv1", "2026-08-10", "allenamento")],
|
||||||
|
presenze: { rv1: { g1: "presente" } },
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra([], evetoAgostoSingolo, OGGI_AGOSTO), "o1").valore,
|
||||||
|
0,
|
||||||
|
"rosa vuota: 0%, non divide per zero (o1)",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra([], evetoAgostoSingolo, OGGI_AGOSTO), "o2").valore,
|
||||||
|
0,
|
||||||
|
"rosa vuota: 0%, non divide per zero (o2)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// o1 conta solo partita+allenamento: se "evento"/"compleanno" trapelassero nel calcolo,
|
||||||
|
// il risultato scenderebbe dal 100% atteso (nessuno "presente" su quei due).
|
||||||
|
const filtriTipo: ContestoObiettivi = {
|
||||||
|
eventi: [
|
||||||
|
evento("ft-partita", "2026-08-05", "partita"),
|
||||||
|
evento("ft-allenamento", "2026-08-06", "allenamento"),
|
||||||
|
evento("ft-evento", "2026-08-07", "evento"),
|
||||||
|
evento("ft-compleanno", "2026-08-08", "compleanno"),
|
||||||
|
],
|
||||||
|
presenze: {
|
||||||
|
"ft-partita": Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])),
|
||||||
|
"ft-allenamento": Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])),
|
||||||
|
"ft-evento": Object.fromEntries(giocatori.map((g) => [g.id, "assente" as const])),
|
||||||
|
"ft-compleanno": {},
|
||||||
|
},
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, filtriTipo, OGGI_AGOSTO), "o1").valore,
|
||||||
|
100,
|
||||||
|
"o1 conta le partite come gli allenamenti, ignora eventi sociali e compleanni",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregazione su più eventi dello stesso mese: 100% su uno, 0% sull'altro = 50% aggregato.
|
||||||
|
const dueEventi: ContestoObiettivi = {
|
||||||
|
eventi: [
|
||||||
|
evento("de1", "2026-08-03", "allenamento"),
|
||||||
|
evento("de2", "2026-08-17", "allenamento"),
|
||||||
|
],
|
||||||
|
presenze: {
|
||||||
|
de1: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])),
|
||||||
|
de2: Object.fromEntries(giocatori.map((g) => [g.id, "assente" as const])),
|
||||||
|
},
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, dueEventi, OGGI_AGOSTO), "o1").valore,
|
||||||
|
50,
|
||||||
|
"o1 aggrega su più eventi dello stesso mese, non solo sull'ultimo",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dueEventiRisposte: ContestoObiettivi = {
|
||||||
|
eventi: [evento("dr1", "2026-08-03", "allenamento"), evento("dr2", "2026-08-17", "partita")],
|
||||||
|
presenze: {
|
||||||
|
dr1: Object.fromEntries(giocatori.map((g) => [g.id, "presente" as const])),
|
||||||
|
dr2: {},
|
||||||
|
},
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, dueEventiRisposte, OGGI_AGOSTO), "o2").valore,
|
||||||
|
50,
|
||||||
|
"o2 aggrega le risposte su più eventi, non solo sull'ultimo",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
|
||||||
|
{
|
||||||
|
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
|
||||||
|
const pizzataSettembre: ContestoObiettivi = {
|
||||||
|
eventi: [evento("p1", "2026-09-12", "evento")],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, pizzataSettembre, OGGI_SETTEMBRE), "o6").valore,
|
||||||
|
1,
|
||||||
|
"l'evento sociale di settembre conta se oggi è settembre",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, pizzataSettembre, OGGI_AGOSTO), "o6").valore,
|
||||||
|
0,
|
||||||
|
"lo stesso evento non conta se oggi è agosto",
|
||||||
|
);
|
||||||
|
|
||||||
|
const allenamentoNelMese: ContestoObiettivi = {
|
||||||
|
eventi: [evento("al1", "2026-08-12", "allenamento")],
|
||||||
|
presenze: {},
|
||||||
|
pagelle: [],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, allenamentoNelMese, OGGI_AGOSTO), "o6").valore,
|
||||||
|
0,
|
||||||
|
"un allenamento nel mese non è un evento sociale",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- somme sulla rosa --------------------------------------------------------
|
// --- somme sulla rosa --------------------------------------------------------
|
||||||
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
|
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
|
||||||
@@ -97,6 +254,15 @@ assert.equal(
|
|||||||
"rosa vuota: nessuna presenza",
|
"rosa vuota: nessuna presenza",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula
|
||||||
|
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30).
|
||||||
|
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! }));
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
|
||||||
|
30,
|
||||||
|
"somma di presenze note, indipendente dal roster reale",
|
||||||
|
);
|
||||||
|
|
||||||
const pagelle: VotoPagella[] = [
|
const pagelle: VotoPagella[] = [
|
||||||
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
||||||
{ match_id: "m1", votante_id: "g2", votato_id: "g1", voto: 8 },
|
{ match_id: "m1", votante_id: "g2", votato_id: "g1", voto: 8 },
|
||||||
@@ -105,6 +271,113 @@ const conPagelle = obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle });
|
|||||||
assert.equal(trova(conPagelle, "o12").valore, 7.5);
|
assert.equal(trova(conPagelle, "o12").valore, 7.5);
|
||||||
assert.equal(trova(conPagelle, "o13").valore, 2, "conta i voti compilati");
|
assert.equal(trova(conPagelle, "o13").valore, 2, "conta i voti compilati");
|
||||||
|
|
||||||
|
// La media arrotonda a una cifra decimale, non tronca: 23/3 = 7.666... -> 7.7.
|
||||||
|
const pagelleDaArrotondare: VotoPagella[] = [
|
||||||
|
{ match_id: "m2", votante_id: "g1", votato_id: "g2", voto: 7 },
|
||||||
|
{ match_id: "m2", votante_id: "g2", votato_id: "g1", voto: 7 },
|
||||||
|
{ match_id: "m2", votante_id: "g3", votato_id: "g1", voto: 9 },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle: pagelleDaArrotondare }), "o12")
|
||||||
|
.valore,
|
||||||
|
7.7,
|
||||||
|
"media arrotondata a una cifra decimale (23/3 = 7.666... -> 7.7)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// La media aggrega i voti di più partite insieme, non solo dell'ultima.
|
||||||
|
const pagellePiuPartite: VotoPagella[] = [
|
||||||
|
{ match_id: "m3", votante_id: "g1", votato_id: "g2", voto: 5 },
|
||||||
|
{ match_id: "m4", votante_id: "g1", votato_id: "g2", voto: 9 },
|
||||||
|
];
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle: pagellePiuPartite }), "o12")
|
||||||
|
.valore,
|
||||||
|
7,
|
||||||
|
"la media aggrega i voti di più partite, non guarda solo una match_id",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(giocatori, { ...contestoVuoto, pagelle: pagellePiuPartite }), "o13")
|
||||||
|
.valore,
|
||||||
|
2,
|
||||||
|
"il conteggio somma i voti di più match_id, non solo dell'ultima",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- o11: continuità di squadra (giocatori con serieAllenamenti >= 3) --------
|
||||||
|
{
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra([], contestoVuoto), "o11").valore,
|
||||||
|
0,
|
||||||
|
"rosa vuota: nessuno può essere in serie",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Target 12 su una rosa di 17: il minimo per schierare due sestetti (6vs6), non un
|
||||||
|
// valore arbitrario — vedi docs/modules/obiettivi-squadra.md.
|
||||||
|
assert.equal(trova(vuoti, "o11").target, 12, "il target resta 12: minimo per un 6vs6");
|
||||||
|
|
||||||
|
// Il confine è >= 3, non > 3: 2 non basta, 3 sì.
|
||||||
|
const rosaConfine = giocatori
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((g, i) => ({ ...g, serieAllenamenti: [2, 3, 10][i]! }));
|
||||||
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(rosaConfine, contestoVuoto), "o11").valore,
|
||||||
|
2,
|
||||||
|
"conta solo chi ha almeno 3 allenamenti consecutivi (2 non basta, 3 sì)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- o3/o4/o5: vittorie in campionato (1/5/10), tutte cappate al target -------
|
||||||
|
{
|
||||||
|
const conVittorie = (n: number) => obiettiviSquadra(giocatori, { ...contestoVuoto, vittorie: n });
|
||||||
|
|
||||||
|
// Senza dato CSI (undefined) o a zero vittorie: tutti fermi a 0, nessun NaN.
|
||||||
|
for (const senzaVittorie of [contestoVuoto, { ...contestoVuoto, vittorie: 0 }]) {
|
||||||
|
const obiettivi = obiettiviSquadra(giocatori, senzaVittorie);
|
||||||
|
assert.equal(trova(obiettivi, "o3").valore, 0);
|
||||||
|
assert.equal(trova(obiettivi, "o4").valore, 0);
|
||||||
|
assert.equal(trova(obiettivi, "o5").valore, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// I target restano fissi: 1, 5, 10.
|
||||||
|
assert.deepEqual(
|
||||||
|
[trova(vuoti, "o3").target, trova(vuoti, "o4").target, trova(vuoti, "o5").target],
|
||||||
|
[1, 5, 10],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Progressione realistica: con 1 vittoria tutti e tre valgono 1 (o3 già al target,
|
||||||
|
// o4/o5 solo all'inizio).
|
||||||
|
assert.deepEqual(
|
||||||
|
[1, 2, 3].map((n) => trova(conVittorie(1), `o${n + 2}`).valore),
|
||||||
|
[1, 1, 1],
|
||||||
|
"1 vittoria: o3 al target, o4/o5 ancora lontani ma valgono 1",
|
||||||
|
);
|
||||||
|
const conCinque = conVittorie(5);
|
||||||
|
assert.deepEqual(
|
||||||
|
[trova(conCinque, "o3").valore, trova(conCinque, "o4").valore, trova(conCinque, "o5").valore],
|
||||||
|
[1, 5, 5],
|
||||||
|
"5 vittorie: o3 e o4 al target, o5 a metà",
|
||||||
|
);
|
||||||
|
const conDieci = conVittorie(10);
|
||||||
|
assert.deepEqual(
|
||||||
|
[trova(conDieci, "o3").valore, trova(conDieci, "o4").valore, trova(conDieci, "o5").valore],
|
||||||
|
[1, 5, 10],
|
||||||
|
"10 vittorie: tutti e tre al target",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Oltre il target: o3 e o4 restavano già cappati con Math.min, o5 no (bug fixato:
|
||||||
|
// valore = vittorie invece di Math.min(vittorie, 10), incoerente con gli altri due e
|
||||||
|
// mostrato senza cap in squadra.tsx/index.tsx come "15/10 vittorie").
|
||||||
|
const conQuindici = conVittorie(15);
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
trova(conQuindici, "o3").valore,
|
||||||
|
trova(conQuindici, "o4").valore,
|
||||||
|
trova(conQuindici, "o5").valore,
|
||||||
|
],
|
||||||
|
[1, 5, 10],
|
||||||
|
"oltre il target tutti e tre restano cappati, o5 incluso",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- progressoObiettivo ------------------------------------------------------
|
// --- progressoObiettivo ------------------------------------------------------
|
||||||
const o = (valore: number, target: number): ObiettivoSquadra => ({
|
const o = (valore: number, target: number): ObiettivoSquadra => ({
|
||||||
id: "x",
|
id: "x",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
mediaSquadra,
|
mediaSquadra,
|
||||||
|
mediaVotoColpoDOcchio,
|
||||||
mediePagelle,
|
mediePagelle,
|
||||||
mieiVoti,
|
mieiVoti,
|
||||||
pagellePartita,
|
pagellePartita,
|
||||||
type VotoPagella,
|
type VotoPagella,
|
||||||
} from "@/lib/pagelle";
|
} from "@/lib/pagelle";
|
||||||
|
import { VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
||||||
|
|
||||||
const voto = (
|
const voto = (
|
||||||
match_id: string,
|
match_id: string,
|
||||||
@@ -61,4 +63,17 @@ assert.deepEqual(mieiVoti(voti, "m1", "g99"), {}, "chi non ha votato non ha voti
|
|||||||
assert.equal(mediaSquadra(voti), 6.8, "(8+7+6+6)/4 = 6.75 → 6.8");
|
assert.equal(mediaSquadra(voti), 6.8, "(8+7+6+6)/4 = 6.75 → 6.8");
|
||||||
assert.equal(mediaSquadra([]), 0, "nessun voto: media zero, non NaN");
|
assert.equal(mediaSquadra([]), 0, "nessun voto: media zero, non NaN");
|
||||||
|
|
||||||
|
// --- mediaVotoColpoDOcchio: soglia minima per la StatTile home (DD-028) ------
|
||||||
|
assert.equal(
|
||||||
|
mediaVotoColpoDOcchio({ mediaVoto: 10, votiPagella: VOTI_MINIMI_PAGELLA - 1 }),
|
||||||
|
"—",
|
||||||
|
"sotto soglia: nascosta anche con media altissima",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
mediaVotoColpoDOcchio({ mediaVoto: 7.5, votiPagella: VOTI_MINIMI_PAGELLA }),
|
||||||
|
7.5,
|
||||||
|
"esattamente in soglia: la media conta",
|
||||||
|
);
|
||||||
|
assert.equal(mediaVotoColpoDOcchio({ mediaVoto: 0, votiPagella: 0 }), "—", "nessun voto ricevuto");
|
||||||
|
|
||||||
console.log("pagelle: ok");
|
console.log("pagelle: ok");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
eventoPrecedente,
|
eventoPrecedente,
|
||||||
eventoSuccessivo,
|
eventoSuccessivo,
|
||||||
oggiISO,
|
oggiISO,
|
||||||
|
serieConsecutivaPalloni,
|
||||||
} from "@/lib/palloni-core";
|
} from "@/lib/palloni-core";
|
||||||
|
|
||||||
const rosa = giocatori.map((g) => ({ id: g.id, nome: g.nome }));
|
const rosa = giocatori.map((g) => ({ id: g.id, nome: g.nome }));
|
||||||
@@ -121,6 +122,18 @@ assert.deepEqual(
|
|||||||
"nessun turno assegnato: conteggio vuoto",
|
"nessun turno assegnato: conteggio vuoto",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A differenza di `eventiPalloni()` (che scarta i compleanni), `conteggioTurni()` non filtra
|
||||||
|
// per tipo: guarda solo `turni` ed `e.data < oggi`. Un turno registrato per errore su un
|
||||||
|
// evento che il resto del modulo tratterebbe come "non richiede palloni" conterebbe comunque
|
||||||
|
// per il badge. Comportamento attuale documentato (non l'UI non offre questa combinazione),
|
||||||
|
// non una correzione: se cambia, questo test deve fallire e ricordarlo.
|
||||||
|
const conCompleanno: Evento[] = [evento("cb1", "2026-09-01", "compleanno")];
|
||||||
|
assert.deepEqual(
|
||||||
|
conteggioTurni({ cb1: "g1" }, conCompleanno, OGGI_CONTEGGIO),
|
||||||
|
{ g1: 1 },
|
||||||
|
"conteggioTurni() non esclude i compleanni come fa eventiPalloni(): nessun filtro per tipo",
|
||||||
|
);
|
||||||
|
|
||||||
// --- oggiISO -------------------------------------------------------------------
|
// --- oggiISO -------------------------------------------------------------------
|
||||||
assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/);
|
assert.match(oggiISO(), /^\d{4}-\d{2}-\d{2}$/);
|
||||||
// Stesso controllo di dataOggi() in scout-live.test.ts: oggiISO() ne è un alias, il fuso
|
// Stesso controllo di dataOggi() in scout-live.test.ts: oggiISO() ne è un alias, il fuso
|
||||||
@@ -201,4 +214,55 @@ assert.deepEqual(
|
|||||||
"senza turni assegnati non c'è nessuno da avvisare",
|
"senza turni assegnati non c'è nessuno da avvisare",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- serieConsecutivaPalloni: volte consecutive in cui li ha portati lui -----
|
||||||
|
const eventiSerie: Evento[] = [
|
||||||
|
evento("s1", "2026-04-01", "partita"),
|
||||||
|
evento("s2", "2026-04-02", "partita"),
|
||||||
|
evento("s3", "2026-04-03", "partita"),
|
||||||
|
evento("s4", "2026-04-04", "partita"),
|
||||||
|
evento("s5", "2026-04-05", "partita"), // futuro rispetto a OGGI_SERIE
|
||||||
|
];
|
||||||
|
const OGGI_SERIE = "2026-04-05";
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni(
|
||||||
|
"g1",
|
||||||
|
{ s1: "g1", s2: "g1", s3: "g2", s4: "g1" },
|
||||||
|
eventiSerie,
|
||||||
|
OGGI_SERIE,
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
"un turno di un altro giocatore azzera la serie: conta solo l'ultimo tratto consecutivo",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni(
|
||||||
|
"g2",
|
||||||
|
{ s1: "g1", s2: "g1", s3: "g2", s4: "g1" },
|
||||||
|
eventiSerie,
|
||||||
|
OGGI_SERIE,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
"l'ultimo evento passato non è suo: serie a zero anche se ne ha uno nel mezzo",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni("g1", { s1: "g1", s2: "g1", s3: "g1" }, eventiSerie, OGGI_SERIE),
|
||||||
|
3,
|
||||||
|
"tre volte di fila fino all'ultimo evento passato",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni("g1", { s1: "g1", s2: "g1", s4: "g1" }, eventiSerie, OGGI_SERIE),
|
||||||
|
3,
|
||||||
|
"s3 senza turno confermato non spezza la serie: viene saltato, non conta contro nessuno",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni("g1", { s1: "g1", s5: "g1" }, eventiSerie, OGGI_SERIE),
|
||||||
|
1,
|
||||||
|
"il turno dell'evento futuro (s5) non è ancora contato",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConsecutivaPalloni("g1", {}, eventiSerie, OGGI_SERIE),
|
||||||
|
0,
|
||||||
|
"nessun turno assegnato: nessuna serie",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("palloni-core: ok");
|
console.log("palloni-core: ok");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import type { Evento } from "@/lib/eventi";
|
import type { Evento } from "@/lib/eventi";
|
||||||
import {
|
import {
|
||||||
conRisposta,
|
conRisposta,
|
||||||
|
contaPartiteGiocate,
|
||||||
contaPresenzeGiocatore,
|
contaPresenzeGiocatore,
|
||||||
destinatariSollecito,
|
destinatariSollecito,
|
||||||
serieConferme,
|
serieConferme,
|
||||||
@@ -91,6 +92,19 @@ assert.equal(totaliEventiGiocatore("g1", eventi, OGGI), 5, "gli eventi futuri no
|
|||||||
assert.equal(contaPresenzeGiocatore("g2", eventi, presenze, OGGI), 0, "chi non risponde è a zero");
|
assert.equal(contaPresenzeGiocatore("g2", eventi, presenze, OGGI), 0, "chi non risponde è a zero");
|
||||||
assert.equal(totaliEventiGiocatore("g2", eventi, OGGI), 5, "il denominatore è uguale per tutti");
|
assert.equal(totaliEventiGiocatore("g2", eventi, OGGI), 5, "il denominatore è uguale per tutti");
|
||||||
|
|
||||||
|
// contaPartiteGiocate esclude gli allenamenti: su 5 eventi passati (4 allenamenti, 1 partita
|
||||||
|
// p1, presente), conta solo p1.
|
||||||
|
assert.equal(
|
||||||
|
contaPartiteGiocate("g1", eventi, presenze, OGGI),
|
||||||
|
1,
|
||||||
|
"solo la partita p1: gli allenamenti non contano come partite giocate",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
contaPresenzeGiocatore("g1", eventi, presenze, OGGI, "allenamento"),
|
||||||
|
3,
|
||||||
|
"contaPresenzeGiocatore con tipo esplicito filtra come contaPartiteGiocate ma per allenamenti",
|
||||||
|
);
|
||||||
|
|
||||||
// Solo partite e allenamenti: compleanni e altri eventi restano fuori.
|
// Solo partite e allenamenti: compleanni e altri eventi restano fuori.
|
||||||
const conAltri: Evento[] = [
|
const conAltri: Evento[] = [
|
||||||
...eventi,
|
...eventi,
|
||||||
@@ -176,6 +190,54 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero");
|
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 serie "Conferme 24h" mente fino al refresh.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/** Check della classifica della rosa: `bun test/unit/rosa.test.ts`. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { classificaRank, dettaglioClassifica } from "@/lib/rosa";
|
||||||
|
|
||||||
|
// --- classificaRank: dense rank, a parità di valore stessa posizione -----------
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
classificaRank([4, 4, 3]),
|
||||||
|
[1, 1, 2],
|
||||||
|
"due giocatori a pari merito condividono la posizione, il numero successivo non salta",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
classificaRank([5, 4, 3, 2]),
|
||||||
|
[1, 2, 3, 4],
|
||||||
|
"nessun pareggio: posizione progressiva normale",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
classificaRank([3, 3, 3, 1]),
|
||||||
|
[1, 1, 1, 2],
|
||||||
|
"tre giocatori a pari merito in testa condividono tutti la posizione #1",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(classificaRank([]), [], "rosa vuota: nessuna posizione");
|
||||||
|
|
||||||
|
assert.deepEqual(classificaRank([0, 0]), [1, 1], "parità anche a valore zero");
|
||||||
|
|
||||||
|
// --- dettaglioClassifica: il sottotitolo segue il criterio selezionato -------
|
||||||
|
|
||||||
|
const g = { streak: 12, votiPagella: 18, partiteGiocate: 15, cacche: 3, seriePalloni: 4 };
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
dettaglioClassifica(g, "presenze"),
|
||||||
|
"12 presenze consecutive",
|
||||||
|
"presenze: mostra la serie di presenze consecutive",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
dettaglioClassifica(g, "palloni"),
|
||||||
|
"4 volte consecutive",
|
||||||
|
"palloni: mostra le volte consecutive in cui ha portato i palloni, non le presenze",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
dettaglioClassifica(g, "mediaVoto"),
|
||||||
|
"18 voti pagella",
|
||||||
|
"media voto: mostra quanti voti compongono la media",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
dettaglioClassifica(g, "mvp"),
|
||||||
|
"15 partite giocate",
|
||||||
|
"mvp: mostra le partite giocate (non gli eventi totali, che includerebbero gli allenamenti)",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
dettaglioClassifica(g, "cacchePartita"),
|
||||||
|
"3 giornate top",
|
||||||
|
"cacche: mostra le giornate da primo classificato",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("rosa: ok");
|
||||||
Reference in New Issue
Block a user