26 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 5281c0c62b Scout: nomi mai spezzati a metà parola, riquadri a 3 colonne sotto i 400 px
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:45:43 +01:00
davideandClaude Sonnet 5.5 3a892a78e6 Versione 1.6.2
Layout corretto sugli schermi stretti (360-402 px) e Ace, Attacchi, Muri e Punti dai +
dello scout in Squadra e Profilo. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:42:39 +01:00
davideandClaude Sonnet 5.5 1e4ec9113e Squadra e Profilo: ace, attacchi, muri e punti dai + dello scout
Squadra → Stats: punti, ace e muri di squadra sommano i + delle valutazioni scout di tutte
le partite. Profilo → Season: al posto di «Turni palloni» compaiono Attacchi, Muri, Ace e
Punti (totale) del giocatore. Le partite del metodo precedente contano con le azioni.
DD-008 aggiornata: dati solo informativi, fuori da badge e classifiche.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:33:49 +01:00
davideandClaude Sonnet 5.5 263621456d Layout su schermi stretti: scout e griglie non tagliati a 360 px
Tabella scout senza larghezza minima (nome a capo, colonne compatte), griglie con
min-width 0, niente scroll orizzontale della pagina, campi a 16px contro lo zoom iOS.
Fixes #12

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 09:54:10 +01:00
davideandClaude Sonnet 5.5 7b93f92965 Versione 1.6.1
Changelog aggiornato con le modifiche dal tag v1.6.0: scout leggibile
nella pagina partita, classifica cacche completa, pagelle senza assenti,
pagina partita a sezioni a scomparsa. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:53:48 +01:00
davideandClaude Sonnet 5.5 c98e1ef218 Pagina partita: sezioni a scomparsa
Formazioni, badge, sondaggio, pagelle, scout e rosa diventano sezioni a
scomparsa; aperti all'inizio solo rosa e presenze e il sondaggio nella
sua finestra. Le formazioni indicano la fonte «Livescore CSI», lo scout
il numero di valutazioni. Titoli allineati a 20px e niente vuoto sotto
le sezioni chiuse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:47:36 +01:00
davideandClaude Sonnet 5.5 e6cfb1bf00 Pagelle: esclusi assenti e infortunati della partita
Nell'elenco da votare non compaiono i convocati che hanno risposto
«assente» o «infortunato» a quella partita; per le altre partite
restano votabili come prima. Solo filtro di interfaccia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:41:20 +01:00
davideandClaude Sonnet 5.5 30fb8e23b6 Scout: risultati leggibili nella pagina partita
La sezione «Scout» mostra le valutazioni − / + nell'app, non solo nel CSV:
percentuale di + e conteggi per fondamentale, selettore Totale / Set 1-5,
tabella per giocatore e guida (i) su come leggere i dati. Le azioni
(punti, ace, muri, errori) restano in un blocco a parte per le partite
scoutate col metodo precedente. Il CSV resta per gli admin.

Aggiunge sintesiValutazioni() in scout-store con test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:38:35 +01:00
davideandClaude Sonnet 5.5 91ac7ce65c Sondaggio cacche: classifica completa per tutti i giocatori
Elenco con posizioni a pari merito senza salti, barra proporzionale
e riga personale evidenziata, anche a sondaggio chiuso.
Sezione «Report tecnico» della partita rinominata «Scout».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:25:50 +01:00
davideandClaude Sonnet 5.5 a7b9affbd6 Stato del progetto: 1.6.0 pubblicata
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 09:02:50 +01:00
davideandClaude Sonnet 5.5 7acdc2ba1e Stato del progetto: migration m35-m39 applicate in produzione
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 09:01:57 +01:00
davideandClaude Sonnet 5.5 c1627a0720 Scout live: titolo «Valuta» senza numero
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:58:50 +01:00
davideandClaude Sonnet 5.5 63d1f01be6 Scout live: tolto l'elenco «Ultime azioni»
Nessuna schermata crea più azioni, quindi l'elenco restava sempre vuoto. Aggiornati modulo e
changelog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:57:34 +01:00
davideandClaude Sonnet 5.5 2787672cd5 Documentazione: allineamento finale per la 1.6.0
Modulo Scout Live riordinato (schermata, salvataggio, export, limiti), glossario e
collegamento CSI: il ripiego sullo scout vale solo per le partite con punteggio.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:56:00 +01:00
davideandClaude Sonnet 5.5 1c63808bfb Porta la versione a 1.6.0: voto MVP definitivo e scout live a valutazioni
Changelog, PROJECT_STATE e package.json/lock alla 1.6.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:52:56 +01:00
davide 92ce3979a3 Merge feature/gara in main: voto MVP e scout live a valutazioni (1.6.0) 2026-11-02 08:51:00 +01:00
davideandClaude Sonnet 5.5 252e1528f6 Messaggio di fine partita, test a inizio stagione e documenti di stato
Il messaggio dopo «Fine partita» dice solo «Partita salvata» (il voto MVP apre 2 ore dopo
l'inizio, non dipende dallo scout). I due test che pretendevano una gara CSI già giocata
tollerano l'inizio stagione (uno si salta). «Calendario ufficiale» passa in «Fatto» nella
roadmap; PROJECT_STATE conta le migration fino alla m39.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:49:57 +01:00
davideandClaude Sonnet 5.5 ebafb98b32 Documentazione: salvataggio immediato e partite scoutate senza punteggio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:36:51 +01:00
davideandClaude Sonnet 5.5 d86b38d47b Scout live: salvataggio immediato e chiusura di set e partita senza punti
Lo stato in corso si salva a ogni tocco (in coda, con avviso se fallisce). «Chiudi set» e
«Fine partita» funzionano con le sole valutazioni; le partite senza punteggio (haPunteggio) non
compaiono come risultato in Home, Classifica e pagina partita. Il pulsante di ripresa riconosce
valutazioni e set chiusi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:36:51 +01:00
davideandClaude Sonnet 5.5 74c78faac5 Documentazione: ordinamento della selezione giocatore e conteggio dei +
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:24:43 +01:00
davideandClaude Sonnet 5.5 5fd7c895b3 Scout live: selezione giocatore ordinabile e conteggio dei + nel riquadro
Il titolo diventa «Selezione giocatore», con un selettore (componente Select dell'app) per
ordinare per cognome o per numero. Nel riquadro di ogni giocatore, al posto dei punti delle
azioni (sempre 0), compaiono i + del set in corso senza la ricezione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:24:42 +01:00
davideandClaude Sonnet 5.5 44194d6d54 Documentazione: scout live ridotto alle valutazioni − / +
Modulo, DD-053, changelog e stato del progetto descrivono la schermata senza tasti dei punti,
tabellone e blocco avversario, e il limite attuale su chiusura di set e partita.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:17:06 +01:00
davideandClaude Sonnet 5.5 51b7a10c99 Scout live: valutazioni − / + per fondamentale e per set
Per ogni convocato un − o un + su battuta, ricezione, attacco e muro, separato per set e
fuori dal punteggio (DD-053, migration m39: colonna scout_partite.valutazioni). Lo stato in
corso le salva in scout_live.stato; il CSV admin ha la tabella per set.

Nella pagina dello scout tolti il blocco avversario/campo, i tasti delle azioni e il
tabellone del punteggio; il titolo della sezione mostra cognome e numero del giocatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 08:16:39 +01:00
davide 09dba1890d Merge main in feature/gara: campionato CSI 2026/27 (1.5.0)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

# Conflicts:
#	PROJECT_STATE.md
#	docs/CHANGELOG.md
2026-10-29 08:34:28 +01:00
davideandClaude Sonnet 5.5 c26b275e5c Porta la versione a 1.5.0: campionato CSI 2026/27
Changelog, PROJECT_STATE e package.json/lock alla 1.5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:26:17 +01:00
davideandClaude Sonnet 5.5 a378a795a4 Campionato CSI: stagione 2026/27
Nuovo project_id (933) e team_id (3878) sul portale Livescore. La Coppa non
esiste quest'anno: CSI_COPPA_PROJECT_ID diventa null e la route non la scarica
piu'. Lo storico 2025/26 non viene piu' mostrato. Test live adattati a inizio
stagione (nessuna gara giocata), doc e changelog aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:18:23 +01:00
41 changed files with 1419 additions and 414 deletions

No files matched your search

+16 -16
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 26/10/2026 Ultimo aggiornamento: 02/11/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo [docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
@@ -9,9 +9,14 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.4.2 in produzione su `main` (tag `v1.4.2`): ogni evento può avere un link Google - **Versione:** 1.6.2 su `main`: layout corretto sugli schermi stretti (360-402 px) e
Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro Ace, Attacchi, Muri e Punti dai + dello scout in Squadra e Profilo, senza migration. La 1.6.1
non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si (tag `v1.6.1`): risultati dello scout leggibili nella pagina partita, classifica cacche
completa e pagina partita a sezioni a scomparsa. La 1.6.0
(tag `v1.6.0`, in produzione dal 02/11/2026): voto MVP definitivo, segreto e a
tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0: vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
@@ -20,24 +25,19 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
[docs/CHANGELOG.md](docs/CHANGELOG.md). [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione - **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con applicate tutte e 50 (`npx supabase db push` del 02/11/2026: `m35`-`m39`, verificato con
`npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna `npx supabase migration list --linked`; nessuna migration remota è assente dal repository). Prima
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`** del push è stato salvato lo schema di produzione (solo schema, non i dati).
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
per scelta, non è stato eseguito. Vanno applicate subito prima del rilascio del codice: con le sole
migration il voto MVP dell'app in produzione (1.4.2) smette di funzionare, con il solo codice la
vista dei conteggi non esiste.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0. è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano - **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018). collega al primo accesso (DD-018).
- **Lavoro in corso:** branch `feature/gara` per le funzioni di gara (correzioni e nuove - **Lavoro in corso:** nessuno su branch. Le voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e «Prossimo». Da decidere sullo scout: il pulsante «Annulla» in alto agisce solo sulle azioni, che non
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione si creano più, quindi è sempre spento.
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
## Cose da sapere ## Cose da sapere
+83 -1
View File
@@ -13,10 +13,73 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**. ## [1.6.2] - 2026-11-11
Layout corretto sugli schermi stretti e statistiche tecniche dai + dello scout in Squadra e
Profilo. Nessuna migration.
### Modificato ### Modificato
- Squadra → Stats: «Punti squadra», «Ace squadra» e «Muri squadra» sommano i + dello scout di
tutte le partite (ace = + di battuta, muri = + di muro, punti = + di attacco, battuta e muro);
le partite del metodo precedente contano con le loro azioni.
- Profilo → Season: al posto di «Turni palloni» compaiono Ace, Attacchi, Muri e Punti (totale)
del giocatore, somma dei + dello scout di tutte le partite (DD-008: solo informativi).
- Layout su schermi stretti (360-402 px, iPhone e Android): niente più pagina tagliata in
larghezza. La tabella per giocatore dello scout sta nello schermo (il nome va a capo su due righe), le
griglie di riquadri non sfondano con nomi ed etichette lunghe, la pagina non scorre in
orizzontale e i campi di testo sono a 16 px, così iOS non zooma al tocco. I nomi non si spezzano
mai a metà parola; nello scout live i riquadri dei giocatori sono a 3 colonne sotto i 400 px.
## [1.6.1] - 2026-11-06
Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni
a scomparsa. Nessuna migration.
### Aggiunto
- Pagina partita, sezione «Scout»: i risultati dello scout si leggono nell'app, non solo nel CSV
admin. Per ogni fondamentale: percentuale di +, barra verde/rossa e conteggi di squadra; selettore
«Totale» e «Set 1»…«Set 5» e tabella per giocatore con + / −. Il pulsante ⓘ apre una guida su come
leggere i dati. Le azioni (punti, ace, muri, errori) restano in un blocco a parte per le partite
scoutate col metodo precedente; il CSV resta per gli admin.
### Modificato
- Sondaggio cacche: la classifica completa è visibile a tutti i giocatori (non più solo il podio),
anche a sondaggio chiuso, con posizioni a pari merito senza salti (1°, 1°, 2°), barra
proporzionale e la propria riga evidenziata.
- Pagina partita: formazioni, badge, sondaggio, pagelle, scout e rosa sono sezioni a scomparsa
(aperti all'inizio solo rosa e presenze e, nella sua finestra, il sondaggio). Le formazioni
indicano la fonte «Livescore CSI»; lo scout mostra il numero di valutazioni. I titoli delle
sezioni a scomparsa hanno la stessa dimensione delle altre e le chiuse non lasciano vuoto sotto.
- Pagelle di fine partita: non compaiono più i convocati che risultano assenti o infortunati a
quella partita (solo filtro di interfaccia).
- Pagina partita: la sezione «Report tecnico» si chiama «Scout».
## [1.6.0] - 2026-11-02
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
applicate in produzione il 02/11/2026.
### Aggiunto
- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro,
separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il
CSV admin della partita include una tabella con i conteggi per set.
### Modificato
- Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set
([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le
partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita.
- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
se non riesce, compare un avviso.
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di
ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti.
- Scout live: dopo «Fine partita» il messaggio dice solo «Partita salvata» (il voto MVP apre 2 ore
dopo l'inizio della partita, non dipende dallo scout).
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si - Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
può più cambiare. Il database lo impone (solo gli admin possono correggere un voto). può più cambiare. Il database lo impone (solo gli admin possono correggere un voto).
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in - In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
@@ -27,6 +90,9 @@ Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
- La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le - La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le
partite più vecchie non si votano più. partite più vecchie non si votano più.
- I test non pretendono più una gara CSI già giocata: a inizio stagione il calendario c'è ma i
risultati no (un test si salta finché non c'è una gara giocata).
### Sicurezza ### Sicurezza
- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per - Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per
@@ -36,6 +102,22 @@ Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
(`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In (`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In
produzione non risultavano voti MVP, quindi nessun dato da bonificare. produzione non risultavano voti MVP, quindi nessun dato da bonificare.
## [1.5.0] - 2026-10-29
Campionato CSI 2026/27. Nessuna migration.
### Modificato
- Campionato CSI passato alla stagione 2026/27: nuovo `project_id` (933) e nuovo `team_id`
(3878). La Coppa non c'è quest'anno e la sezione resta nascosta; lo storico 2025/26 non viene
più mostrato.
### Dati
- Calendario di produzione aggiornato a mano con le 12 gare di campionato già definite dal CSI
(4/11/2026 – 11/3/2027): titolo «CAMPIONATO», luogo e link Google Maps dell'impianto. I 9
allenamenti nello stesso giorno di una gara sono stati eliminati.
## [1.4.2] - 2026-10-26 ## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
+1 -1
View File
@@ -88,7 +88,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. | | `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. | | `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. | | `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. |
## Votazioni ## Votazioni
+1 -1
View File
@@ -18,7 +18,7 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). | | **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. | | **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). | | **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
| **Scout / Scout Live** | Rilevazione in tempo reale delle azioni di una partita, con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). | | **Scout / Scout Live** | Rilevazione in tempo reale, durante la partita, delle valutazioni − / + di ogni convocato su battuta, ricezione, attacco e muro, per set (DD-053), con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). | | **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). | | **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). | | **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
+3 -3
View File
@@ -31,9 +31,11 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
resta un'estensione futura resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data - [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048) di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2025/26) - [x] Collegamento CSI (stagione 2026/27)
- [x] Classifica automatica (campionato e Coppa) - [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato - [x] Risultati campionato
- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato:
a inizio stagione il calendario c'è prima dei risultati
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria - [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza - [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
@@ -54,8 +56,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
## Prossimo ## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi - [ ] Database esercizi
- [ ] AI Allenamenti - [ ] AI Allenamenti
- [ ] Archivio allenamenti - [ ] Archivio allenamenti
+3
View File
@@ -17,6 +17,9 @@ Le statistiche **personali** in profilo e squadra devono essere **eque per tutti
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti. - Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
- Lo scout resta strumento tecnico, non gioco. - Lo scout resta strumento tecnico, non gioco.
- Aggiornamento 2026-11-11: in Profilo → Season compaiono, come semplice informazione personale,
i totali di ace, attacchi, muri e punti del giocatore ricavati dai + dello scout. Non entrano
in badge, obiettivi o classifiche: la regola sulla leva competitiva resta valida.
**Riesame** **Riesame**
Se la squadra chiede esplicitamente classifiche tecniche per ruolo. Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
+40
View File
@@ -0,0 +1,40 @@
# DD-053 — Valutazioni − / + dei fondamentali nello Scout Live
**Data:** 2 novembre 2026
**Stato:** Accettata (migration `m39`)
**Contesto**
Lo scout registrava solo ciò che fa punto o lo toglie (attacco, ace, muro, errore). Mancava un
giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non cambia il punteggio.
**Decisione**
- Per ogni convocato, un **−** o un **+** su battuta, ricezione, attacco e muro, **per set**.
- Sono valutazioni, non azioni: lista `valutazioni` separata da `azioni`, così non entrano nel
punteggio, nei parziali né nei totali P/A/M/E (`totaliPerGiocatore`, badge, classifiche: DD-008).
- Ogni tocco è una riga (giocatore, fondamentale, segno, set, istante): permette l'annulla e il
conteggio per set senza strutture da tenere allineate.
- Stato in corso dentro `scout_live.stato` (jsonb, nessuna modifica); partita archiviata nella
nuova colonna `scout_partite.valutazioni jsonb NOT NULL DEFAULT '[]'` (`m39`, solo additiva).
- Nella schermata dello scout, su richiesta, sono stati tolti i tasti delle azioni, il tabellone del
punteggio e il blocco avversario/campo: lo scout è fatto solo di valutazioni. Non c'è più una
«Chiudi set» e «Fine partita» funzionano anche senza punti: il set chiuso vale 0-0 e la partita
archiviata di sole valutazioni **non è un risultato** (`haPunteggio()`), quindi non compare in
Home, Classifica né al posto del referto CSI.
- Il salvataggio dello stato in corso è immediato a ogni tocco, con avviso se fallisce.
**Alternative scartate**
- Nuovi `AzioneTipo` dentro `azioni` → ogni consumatore (punteggio, totali, CSV) andrebbe filtrato
per non contarli come punti.
- Solo contatori aggregati per giocatore/set → niente annulla preciso, né ordine cronologico.
- Tabella dedicata → per un dato che nasce e muore con la partita archiviata è più schema del
necessario.
**Conseguenze**
Le partite già archiviate hanno `valutazioni = []`. Le valutazioni sono visibili solo nel CSV
admin; sono dato di squadra a uso tecnico, non entrano in classifiche individuali.
**Riesame**
Se servirà confrontare le valutazioni tra partite o mostrarle ai giocatori.
+1
View File
@@ -89,6 +89,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento | | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito | | [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura | | [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
| [DD-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-047](DD-047.md) | Notifica di compleanno |
+41 -40
View File
@@ -1,6 +1,6 @@
# Modulo — Collegamento CSI # Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26) **Stato:** implementato (stagione 2026/27)
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id` **Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti) (dettaglio di una gara: formazioni e scontri diretti)
@@ -28,52 +28,50 @@ verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati i
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente. un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26) ### Identificativi (stagione 2026/27)
| Cosa | Valore | | Cosa | Valore |
| --------------------- | -------------------------------------- | | --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza | | Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` | | `project_id` (girone) | `933` |
| Coppa | PVM Coppa CSI Misto Silver | | Coppa | nessuna: la squadra non è iscritta |
| `project_id` (coppa) | `848` | | `project_id` (coppa) | `null` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | | Squadra sul portale | `C.R.A.P. Volley` |
| `team_id` | `3359` | | `team_id` | `3878` |
| Girone | B | | Girone | B (7 squadre) |
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in `project_id` (933), `CSI_COPPA_PROJECT_ID` (`null`) e `team_id` (3878) sono costanti in
`src/lib/csi-core.ts`. `src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora **A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono `3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica` per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel `3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica classifica è tutta a zero (giocate 0): non è un errore di parsing.
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../decisions/DD-032.md)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359` Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
(dietro `team_details.php`) contiene una sezione "Campionati" con un link `team_id` si legge dal link della squadra nella pagina del girone) oppure
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta — `components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
verificato chiamando l'endpoint direttamente, che oggi restituisce sia `league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire `933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
`team_details.php` nel browser, questo componente basta. `CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
### Endpoint usati dall'app ### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente | | 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=933` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=933` (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") | | `components/project-sheets.php?project_id=<Coppa>` | HTML | Classifica del girone di Coppa, solo se `CSI_COPPA_PROJECT_ID` non è `null` (vedi limite 4) | `league_details.php?project_id=<Coppa>` (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`) | | `assets/json/getEventsByTeamId.php?team_id=3878` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3878` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`, **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). `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 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 · `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 `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` fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è (`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa. condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e** **Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
@@ -232,7 +230,8 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
tabella/lista, sia per classifica/partite sia per formazioni/precedenti. 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 che hanno un punteggio (`useScoutMatches()` filtrato con
`haPunteggio()`: quelle di sole valutazioni non sono un risultato).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai `/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 vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina. mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
@@ -245,11 +244,12 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il 1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
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 con punteggio, 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 (vedi 2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`. id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non `csi-core.ts`, e lo storico della stagione precedente sparisce (DD-032 non lo conserva).
L'etichetta «Stagione 2026/27» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`. dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
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
@@ -258,20 +258,21 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
dati in una tabella Supabase riempita da un job cron (stesso pattern di dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`). `promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.** 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 `getEventsByTeamId.php?team_id=3878` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project` 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 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 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 partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`. filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in **La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa `/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona 2025/26 era `project_id=848`: `project-sheets.php` ha la stessa
struttura a tabella-per-girone del campionato, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a 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 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` 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: 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 `project-main.php?project_id=848` (2025/26) 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 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 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 tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
+2
View File
@@ -26,6 +26,8 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
partita (scout salvato o dato CSI). partita (scout salvato o dato CSI).
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia - Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
dal vincolo DB. dal vincolo DB.
- Nell'elenco da votare non compaiono i convocati che hanno risposto «assente» o «infortunato»
alle presenze: solo filtro di interfaccia in `partita.$id.tsx`, il vincolo DB non cambia.
- `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
+8
View File
@@ -285,8 +285,16 @@ Sezione già presente. Contiene.
- Voto medio - Voto medio
- MVP - MVP
- Serie - Serie
- **Ace, Attacchi, Muri e Punti (totale)**: somma, su tutte le partite scoutate, dei soli **+**
assegnati a quel giocatore nello scout live (ace = + di battuta, attacchi = + di attacco,
muri = + di muro; punti = ace + attacchi + muri, la ricezione non conta). Le partite scoutate
col metodo precedente, senza valutazioni, contano con le azioni registrate; se una partita ha
entrambe, valgono solo le valutazioni (`totaliTecnici()` in `scout-store.ts`). Il «Turni
palloni» non compare più qui: il turno resta nel banner palloni e nel badge dedicato.
- Altre statistiche disponibili - Altre statistiche disponibili
Sono numeri informativi: non alimentano badge, obiettivi né classifiche (DD-008).
### Badge ### Badge
Sezione già presente. Sezione già presente.
+70 -17
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live # Modulo — Scout Live
**Stato:** implementato (fix M7 per la persistenza condivisa) **Stato:** implementato (fix M7 per la persistenza condivisa; valutazioni − / + dalla 1.6.0, DD-053)
**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`
@@ -10,9 +10,9 @@
## Obiettivo ## Obiettivo
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita, Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato,
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato per set, con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che chi
aggiornato da qualunque dispositivo. riprende lo scout trovi lo stato aggiornato, da qualunque dispositivo.
--- ---
@@ -20,9 +20,9 @@ aggiornato da qualunque dispositivo.
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per - `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
`evento_id`, quindi un solo detentore). `evento_id`, quindi un solo detentore).
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione. - `scout_live` — stato in corso (valutazioni, set chiusi, avversario e campo) di una sessione.
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni), - `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni,
mai più modificato una volta salvato (solo eliminabile per intero). valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero).
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento - `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
(`UNIQUE evento_id, giocatore_id`). (`UNIQUE evento_id, giocatore_id`).
@@ -65,17 +65,52 @@ perché occupava spazio 6 giorni su 7.
## Cosa registra ## Cosa registra
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`, La schermata `/scout` ha due passi: **Selezione giocatore** e **Valuta** (vedi sotto), seguiti da
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro, «Chiudi set» e «Fine partita».
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati - **Selezione giocatore:** griglia dei convocati (chi ha risposto "presente"/"ritardo", con fallback
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha a tutta la rosa se nessuno ha risposto), ordinata per cognome o per numero con il selettore a
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento. destra del titolo (default cognome). In ogni riquadro, sotto il nome, compaiono i **+** del
giocatore nel set in corso, ricezione esclusa (`contaPiuSenzaRicezione()`).
- **Salvataggio** su `scout_live` a ogni cambiamento, senza attesa: i salvataggi vanno in coda, uno
alla volta e in ordine, e ognuno scrive lo stato intero (uno riuscito rimedia a uno fallito). Se
un salvataggio non riesce compare un avviso fisso, che sparisce al salvataggio successivo riuscito.
**Punteggio e azioni.** L'interfaccia non registra più il punteggio: sono stati tolti i tasti delle
azioni (punto attacco, ace, muro, errore nostro, punto e errore avversario), il tabellone con punti
e set, l'elenco «Ultime azioni» e il blocco avversario / in casa / trasferta. Avversario e campo restano quelli ricavati dal
titolo della partita (o dallo stato già salvato) e non si modificano. I tipi di azione
(`AzioneTipo`, `scout-store.ts`: `attacco`, `ace`, `muro`, `errore`, `punto_avv`, `errore_avv`),
i totali `totaliPerGiocatore()` e il campo `scout_partite.azioni` restano nel codice e nei dati:
leggono le partite scoutate prima di questa modifica, ma oggi nessuna schermata crea nuove azioni.
---
## Valutazioni dei fondamentali (− / +)
Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro
([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai
totali P/A/M/E. Si seleziona il giocatore e si tocca − o + sul fondamentale: il titolo della sezione
mostra cognome e numero del giocatore e il set in corso, e accanto a ogni tasto compare il conteggio
di quel giocatore **nel set in corso**. «Annulla» nella sezione toglie l'ultima valutazione del set in
corso (l'«Annulla» in alto agisce solo sulle azioni, quindi oggi è sempre spento).
Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una
lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()`
(`scout-store.ts`). Lo stato in corso le salva in `scout_live.stato.valutazioni` (assente negli
stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_partite.valutazioni`.
--- ---
## Fine partita ## Fine partita
Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutazione, e
`finePartita()` archivia anche la partita fatta di sole valutazioni: il set in corso entra nei
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde
«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**:
`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
referto CSI; resta visibile il «Scout» con il CSV.
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato) `scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
e rilascia la sessione. e rilascia la sessione.
@@ -84,9 +119,21 @@ e rilascia la sessione.
## Export CSV ## Export CSV
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per `scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per giocatore e
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita, log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
sezione "Report tecnico". i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
pagina partita, sezione "Scout".
---
## Lettura nell'app
La sezione «Scout» della pagina partita (`ScoutReport.tsx`, visibile a chi vede la pagina; il CSV
resta per i soli admin) mostra, per la partita o per un singolo set (selettore in alto, se i set
sono più di uno): per ciascun fondamentale la percentuale di **+**, una barra verde/rossa e i
conteggi di squadra; poi la tabella per giocatore con **+ / −** di ogni fondamentale. I totali
vengono da `sintesiValutazioni()` (`scout-store.ts`). Le azioni (P/A/M/E) compaiono in un blocco a
parte solo se la partita ne ha, cioè quelle scoutate prima di DD-053. Il pulsante (icona i) apre una guida in linea su valutazioni, percentuale, tabella e set.
--- ---
@@ -107,7 +154,7 @@ calcolati a runtime).
## Regole rispettate ## Regole rispettate
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout - **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri e valutazioni) restano confinati allo Scout
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
classifiche individuali. classifiche individuali.
@@ -115,10 +162,15 @@ calcolati a runtime).
## Limiti noti ## Limiti noti
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita»
entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
- Lo scout in corso mostra solo il set in corso: le valutazioni dei set chiusi si rivedono solo
nella pagina partita, dopo «Fine partita».
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica - Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
garanzia è il lock di sessione (vedi sopra). garanzia è il lock di sessione (vedi sopra).
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock: - Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
lettura e upsert non sono atomici. lettura e upsert non sono atomici.
- Avversario e campo non si correggono dallo scout.
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale. - `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due - Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
partite cadono lo stesso giorno. partite cadono lo stesso giorno.
@@ -127,4 +179,5 @@ calcolati a runtime).
## Evoluzioni possibili ## Evoluzioni possibili
- Nello scout, in sola lettura, le valutazioni dei set chiusi.
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
+7
View File
@@ -82,6 +82,13 @@ successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive"). pagella" per il criterio media voto, non sempre "presenze consecutive").
In cima alla tab, i riquadri «Punti squadra», «Ace squadra» e «Muri squadra» sommano **tutte le
partite scoutate** (`totaliSquadraValutazioni()`, che usa `totaliTecnici()`, in `scout-store.ts`), contando solo le valutazioni
**+** dello scout live: «Ace» = + di battuta, «Muri» = + di muro, «Punti» = + di attacco, battuta e
muro (la ricezione non è un punto messo a terra, come nel riquadro del giocatore). Le partite
scoutate col metodo precedente, senza valutazioni, contano con le loro azioni registrate (punti,
ace, muri); se una partita ha entrambe, valgono solo le valutazioni, così non si conta due volte.
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di 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 `useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`. presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.2", "version": "1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.4.2", "version": "1.6.2",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.4.2", "version": "1.6.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+290
View File
@@ -0,0 +1,290 @@
import { useState } from "react";
import { BarChart3, Download, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import type { Giocatore } from "@/lib/crapp-data";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import {
fondamentali,
sintesiValutazioni,
totaliPerGiocatore,
totaliSquadra,
type ScoutMatch,
} from "@/lib/scout-store";
const pct = (piu: number, meno: number) =>
piu + meno === 0 ? null : Math.round((piu / (piu + meno)) * 100);
/** Lettura dello scout di una partita: squadra per fondamentale, set per set, dettaglio per giocatore. */
export function ScoutReport({
scout,
rosa,
admin,
}: {
scout: ScoutMatch;
rosa: Giocatore[];
admin: boolean;
}) {
// undefined = tutta la partita.
const [set, setSet] = useState<number | undefined>(undefined);
const [guida, setGuida] = useState(false);
const sets = [...new Set(scout.valutazioni.map((v) => v.set))].sort((a, b) => a - b);
const { squadra, perGiocatore } = sintesiValutazioni(scout.valutazioni, set);
const righe = perGiocatore
.map((r) => ({ ...r, g: rosa.find((x) => x.id === r.giocatoreId) }))
.filter((r): r is typeof r & { g: Giocatore } => !!r.g)
.sort((a, b) => a.g.numero - b.g.numero);
const azioni = totaliPerGiocatore(scout.azioni);
const totaliAzioni = totaliSquadra([scout]);
return (
<Card>
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<BarChart3 className="h-3.5 w-3.5" /> Valutazione scout
</p>
<button
type="button"
aria-expanded={guida}
aria-label="Come leggere lo scout"
onClick={() => setGuida((g) => !g)}
className={cn(
"premi flex h-8 w-8 items-center justify-center rounded-full transition-colors",
guida ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground",
)}
>
<Info className="h-4 w-4" />
</button>
</div>
{guida ? (
<dl className="mt-3 space-y-3 rounded-2xl bg-secondary p-4 text-[13px] leading-relaxed">
{[
[
"Cosa misura",
<>
Durante la partita lo scout assegna a ciascun giocatore una valutazione{" "}
<b className="text-success">+</b> (esecuzione positiva) o{" "}
<b className="text-destructive">−</b> (esecuzione negativa) su quattro fondamentali:
battuta, ricezione, attacco e muro. Sono giudizi qualitativi, non punti: non
incidono su risultato, classifiche o badge.
</>,
],
[
"Indicatori di squadra",
<>
Ogni scheda riporta, per fondamentale, la percentuale di valutazioni positive (+ ÷
(+ + −)), la barra di composizione verde/rossa e il numero di + e −. Un valore alto
indica un fondamentale eseguito con continuità.
</>,
],
[
"Dettaglio per giocatore",
<>
La tabella elenca, per ogni giocatore, i + e i − di ciascun fondamentale nel formato{" "}
<b>+ / −</b>: «3 / 1» indica tre valutazioni positive e una negativa. Compaiono solo
i giocatori con almeno una valutazione nel periodo selezionato.
</>,
],
[
"Periodo",
<>
«Totale» aggrega l'intera partita; «Set 1», «Set 2»… isolano un singolo set e
permettono di osservare l'andamento durante la gara.
</>,
],
[
"Interpretazione",
<>
Il campione è ridotto: con pochi tocchi la percentuale è poco significativa (un solo
+ su un solo tocco vale 100%). I dati riflettono il giudizio dello scout in servizio
e vanno letti come indicazione di squadra, non come voto individuale.
</>,
],
[
"Partite precedenti",
<>
Per le partite scoutate con il metodo precedente è mostrato anche il riepilogo delle
azioni registrate (punti, ace, muri, errori).
</>,
],
].map(([titolo, testo]) => (
<div key={titolo as string}>
<dt className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo}
</dt>
<dd className="mt-0.5">{testo}</dd>
</div>
))}
</dl>
) : null}
{scout.valutazioni.length > 0 ? (
<>
{sets.length > 1 ? (
<div
role="tablist"
aria-label="Periodo"
className="mt-3 grid grid-flow-col auto-cols-fr gap-0.5 rounded-2xl bg-secondary p-1"
>
{[undefined, ...sets].map((s) => (
<button
key={s ?? "tutta"}
type="button"
role="tab"
aria-selected={s === set}
onClick={() => setSet(s)}
className={cn(
"min-w-0 truncate rounded-xl px-0 py-2 text-[13px] font-bold uppercase transition-colors",
s === set
? "bg-primary text-primary-foreground shadow-card"
: "text-muted-foreground",
)}
>
{s === undefined ? "Totale" : `Set ${s}`}
</button>
))}
</div>
) : null}
<div className="mt-3 grid grid-cols-2 gap-2">
{fondamentali.map((f) => {
const { piu, meno } = squadra[f.id];
const p = pct(piu, meno);
return (
<div key={f.id} className="rounded-2xl bg-secondary p-3">
<div className="flex items-baseline justify-between">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{f.label}
</p>
<p className="font-display text-xl leading-none">
{p === null ? "—" : `${p}%`}
</p>
</div>
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-border">
<span className="bg-success" style={{ width: `${p ?? 0}%` }} />
<span
className="bg-destructive"
style={{ width: p === null ? 0 : `${100 - p}%` }}
/>
</div>
<p className="mt-2 text-[13px] font-semibold tabular-nums">
<span className="text-success">+{piu}</span>
<span className="mx-1.5 text-muted-foreground">·</span>
<span className="text-destructive">−{meno}</span>
</p>
</div>
);
})}
</div>
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Dettaglio giocatori
</p>
{righe.length === 0 ? (
<p className="mt-2 text-[13px] text-muted-foreground">
Nessuna valutazione in questo set.
</p>
) : (
<div className="mt-2 overflow-hidden rounded-2xl bg-secondary/60">
<table className="w-full table-fixed text-[13px]">
<thead>
<tr className="text-left text-[13px] uppercase text-muted-foreground">
<th className="whitespace-nowrap px-2 py-2 font-bold">Giocatore</th>
{fondamentali.map((f) => (
<th key={f.id} className="w-11 px-0 py-2 text-center font-bold">
{f.label.slice(0, 3)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{righe.map(({ g, conteggi }) => (
<tr key={g.id}>
<td className="px-2 py-2 font-semibold leading-tight [overflow-wrap:normal]">
<span className="mr-1.5 tabular-nums text-muted-foreground">
#{g.numero}
</span>
{g.nome}
</td>
{fondamentali.map((f) => (
<td key={f.id} className="px-0 py-2 text-center font-semibold tabular-nums">
<span className="text-success">{conteggi[f.id].piu}</span>
<span className="text-muted-foreground">/</span>
<span className="text-destructive">{conteggi[f.id].meno}</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="mt-2 text-[13px] text-muted-foreground">
Valutazioni: <span className="text-success">+</span> positive /{" "}
<span className="text-destructive">−</span> negative. La percentuale è la quota di +.
</p>
</>
) : null}
{scout.azioni.length > 0 ? (
<>
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Azioni registrate
</p>
<div className="mt-2 grid grid-cols-4 gap-2 text-center">
{[
{ l: "Punti", v: totaliAzioni.punti },
{ l: "Ace", v: totaliAzioni.ace },
{ l: "Muri", v: totaliAzioni.muri },
{ l: "Errori", v: totaliAzioni.errori },
].map((t) => (
<div key={t.l} className="rounded-2xl bg-secondary px-1 py-2.5">
<p className="font-display text-xl leading-none">{t.v}</p>
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
{t.l}
</p>
</div>
))}
</div>
<div className="mt-2 space-y-1">
{[...azioni.entries()].map(([gid, t]) => {
const g = rosa.find((x) => x.id === gid);
if (!g) return null;
return (
<div
key={gid}
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
>
<span className="min-w-0 flex-1 truncate font-semibold">
#{g.numero} {g.nome}
</span>
<span className="tabular-nums text-muted-foreground">
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
</span>
</div>
);
})}
</div>
</>
) : null}
{scout.valutazioni.length === 0 && scout.azioni.length === 0 ? (
<p className="text-[13px] text-muted-foreground">
Nessun dato registrato per questa partita.
</p>
) : null}
{admin ? (
<button
type="button"
onClick={() =>
scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout, rosa))
}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV
</button>
) : null}
</Card>
);
}
+66 -15
View File
@@ -40,11 +40,15 @@ export function SondaggioCacche({
const media = mediaPartita(righe, eventoId); const media = mediaPartita(righe, eventoId);
const classifica = [...dellaPartita] const classifica = [...dellaPartita]
.sort((a, b) => b.quantita - a.quantita) .sort((a, b) => b.quantita - a.quantita)
.slice(0, 3)
.map((r) => { .map((r) => {
const g = squadra.find((g) => g.id === r.giocatore_id); const g = squadra.find((g) => g.id === r.giocatore_id);
return { ...r, nome: g ? nomeCompleto(g) : "—" }; return { ...r, nome: g ? nomeCompleto(g) : "—" };
}); })
// Pari merito = stessa posizione, senza salti (1°, 1°, 2°).
.map((r, _, tutti) => ({
...r,
pos: new Set(tutti.filter((t) => t.quantita > r.quantita).map((t) => t.quantita)).size + 1,
}));
async function rispondi(quantita: number) { async function rispondi(quantita: number) {
if (!io) return; if (!io) return;
@@ -89,6 +93,7 @@ export function SondaggioCacche({
? "Sondaggio chiuso: era aperto fino al fischio d'inizio." ? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."} : "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
</p> </p>
<Classifica righe={classifica} io={io?.id} media={media} />
</Card> </Card>
); );
} }
@@ -126,19 +131,7 @@ export function SondaggioCacche({
))} ))}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]"> <Classifica righe={classifica} io={io?.id} media={media} />
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
Media squadra {media}
</span>
{classifica.map((r, i) => (
<span
key={r.giocatore_id}
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
>
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
</span>
))}
</div>
{admin ? ( {admin ? (
<button <button
@@ -154,3 +147,61 @@ export function SondaggioCacche({
</Card> </Card>
); );
} }
const MEDAGLIE = ["🥇", "🥈", "🥉"];
type RigaClassifica = { giocatore_id: string; nome: string; quantita: number; pos: number };
function Classifica({
righe,
io,
media,
}: {
righe: RigaClassifica[];
io: string | undefined;
media: number | string;
}) {
if (righe.length === 0) return null;
const max = Math.max(...righe.map((r) => r.quantita), 1);
return (
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-[13px]">
<span className="font-bold uppercase tracking-wide text-muted-foreground">Classifica</span>
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
Media squadra {media}
</span>
</div>
<ol className="divide-y divide-border overflow-hidden rounded-2xl bg-secondary/60">
{righe.map((r) => (
<li
key={r.giocatore_id}
className={cn(
"relative flex items-center gap-3 px-3 py-2.5",
r.giocatore_id === io && "bg-accent/15",
)}
>
<span
aria-hidden
className="absolute inset-y-0 left-0 bg-accent/10"
style={{ width: `${(r.quantita / max) * 100}%` }}
/>
<span className="relative w-7 text-center text-[15px] font-bold tabular-nums text-muted-foreground">
{r.pos <= 3 ? MEDAGLIE[r.pos - 1] : `${r.pos}°`}
</span>
<span className="relative min-w-0 flex-1 truncate text-[15px] font-semibold">
{r.nome}
{r.giocatore_id === io ? (
<span className="ml-1.5 text-[13px] font-bold uppercase text-muted-foreground">
tu
</span>
) : null}
</span>
<span className="relative text-[17px] font-bold tabular-nums">
{r.quantita === 5 ? "5+" : r.quantita}
</span>
</li>
))}
</ol>
</div>
);
}
+8 -3
View File
@@ -310,11 +310,14 @@ export function SezioneTendina({
<button <button
type="button" type="button"
onClick={() => setAperta((v) => !v)} onClick={() => setAperta((v) => !v)}
className="mb-3 flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]" className={cn(
"flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]",
(aperta || anteprima) && "mb-3",
)}
aria-expanded={aperta} aria-expanded={aperta}
aria-controls={id} aria-controls={id}
> >
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2> <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2>
<span className="flex shrink-0 items-center gap-2"> <span className="flex shrink-0 items-center gap-2">
{azione} {azione}
<ChevronDown <ChevronDown
@@ -336,9 +339,11 @@ export function SezioneTendina({
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche * `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
* con `appearance-none`, restano un filo più alti di un input di testo a * con `appearance-none`, restano un filo più alti di un input di testo a
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro. * parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
* `text-base` (16px) non è estetica: sotto i 16px iOS Safari zooma la pagina al focus e,
* dopo il blur, la lascia ingrandita e tagliata in larghezza.
*/ */
export const classiInput = export const classiInput =
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]"; "h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-base";
/** /**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label * Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
+3
View File
@@ -536,6 +536,7 @@ export type Database = {
parziali: Json; parziali: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni: Json;
}; };
Insert: { Insert: {
avversario: string; avversario: string;
@@ -548,6 +549,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni?: Json;
}; };
Update: { Update: {
avversario?: string; avversario?: string;
@@ -560,6 +562,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro?: number; set_loro?: number;
set_nostri?: number; set_nostri?: number;
valutazioni?: Json;
}; };
Relationships: []; Relationships: [];
}; };
+10 -9
View File
@@ -8,16 +8,17 @@ 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 2026/27. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767; export const CSI_PROJECT_ID = 933;
/** /**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone * Coppa CSI della stagione in corso, `null` se la squadra non è iscritta (nel 2026/27 non c'è).
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a * Stesso formato di tabella del campionato (girone all'italiana), solo con meno squadre:
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md. * `parseClassifica()` funziona invariata. Cambia a ogni stagione come CSI_PROJECT_ID, vedi
* docs/modules/collegamento-csi.md.
*/ */
export const CSI_COPPA_PROJECT_ID = 848; export const CSI_COPPA_PROJECT_ID: number | null = null;
/** C.R.A.P. Volley sul portale CSI. */ /** C.R.A.P. Volley sul portale CSI: l'id cambia a ogni stagione (2025/26 era 3359). */
export const CSI_TEAM_ID = 3359; export const CSI_TEAM_ID = 3878;
export const CSI_GIRONE = "Girone B"; export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley"; export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
@@ -133,7 +134,7 @@ export type DettaglioPartitaCsi = {
export type DatiCsi = { export type DatiCsi = {
classifica: RigaClassifica[]; classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */ /** Classifica del girone di Coppa (`CSI_COPPA_PROJECT_ID`). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[]; classificaCoppa: RigaClassifica[];
partite: PartitaCsi[]; partite: PartitaCsi[];
girone: string; girone: string;
+2 -1
View File
@@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string {
export function useAnagraficaRosa({ export function useAnagraficaRosa({
conAllenatori = false, conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array< }: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
> { > {
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
return useMemo( return useMemo(
@@ -44,6 +44,7 @@ export function useAnagraficaRosa({
.map((g) => ({ .map((g) => ({
id: g.id, id: g.id,
nome: nomeCompleto(g), nome: nomeCompleto(g),
cognome: g.cognome,
ruolo: g.ruolo, ruolo: g.ruolo,
numero: g.numero, numero: g.numero,
nascita: g.nascita ?? "", nascita: g.nascita ?? "",
+33 -1
View File
@@ -1,5 +1,11 @@
import type { Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; import {
azioniMeta,
contaValutazioni,
fondamentali,
totaliPerGiocatore,
type ScoutMatch,
} from "./scout-store";
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ /** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
export function rigaCsv(campi: Array<string | number>) { export function rigaCsv(campi: Array<string | number>) {
@@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
if (!t) continue; if (!t) continue;
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
} }
if (match.valutazioni.length > 0) {
righe.push("");
righe.push(
rigaCsv([
"Set",
"Numero",
"Giocatore",
...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]),
]),
);
const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y);
for (const set of sets) {
for (const g of rosa) {
if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue;
const c = contaValutazioni(match.valutazioni, g.id, set);
righe.push(
rigaCsv([
set,
g.numero,
g.nome,
...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]),
]),
);
}
}
}
righe.push(""); righe.push("");
righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
for (const a of match.azioni) { for (const a of match.azioni) {
+4 -1
View File
@@ -1,10 +1,12 @@
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 type { Azione } from "./scout-store"; import type { Azione, Valutazione } from "./scout-store";
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */ /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
export type StatoScout = { export type StatoScout = {
azioni: Azione[]; azioni: Azione[];
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
valutazioni?: Valutazione[];
setChiusi: Array<[number, number]>; setChiusi: Array<[number, number]>;
avversario: string; avversario: string;
casa: boolean; casa: boolean;
@@ -12,6 +14,7 @@ export type StatoScout = {
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({ export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
azioni: [], azioni: [],
valutazioni: [],
setChiusi: [], setChiusi: [],
avversario, avversario,
casa, casa,
+138 -1
View File
@@ -60,6 +60,90 @@ export type Azione = {
ts: number; ts: number;
}; };
/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */
export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro";
export const fondamentali: Array<{ id: Fondamentale; label: string }> = [
{ id: "battuta", label: "Battuta" },
{ id: "ricezione", label: "Ricezione" },
{ id: "attacco", label: "Attacco" },
{ id: "muro", label: "Muro" },
];
export type Valutazione = {
id: string;
giocatoreId: string;
fondamentale: Fondamentale;
segno: "+" | "-";
set: number;
ts: number;
};
export type ConteggioValutazioni = Record<Fondamentale, { piu: number; meno: number }>;
/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */
export function contaValutazioni(
valutazioni: Valutazione[],
giocatoreId: string,
set?: number,
): ConteggioValutazioni {
const out = {} as ConteggioValutazioni;
for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 };
for (const v of valutazioni) {
if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue;
out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1;
}
return out;
}
/**
* Riepilogo delle valutazioni per la lettura nell'app: totali di squadra e per giocatore,
* su tutta la partita o su un solo set. Chi non ha valutazioni nel periodo non compare.
*/
export function sintesiValutazioni(valutazioni: Valutazione[], set?: number) {
const filtrate = set === undefined ? valutazioni : valutazioni.filter((v) => v.set === set);
const squadra = contaValutazioni([], "", undefined);
const perGiocatore = [...new Set(filtrate.map((v) => v.giocatoreId))].map((giocatoreId) => {
const c = contaValutazioni(filtrate, giocatoreId);
for (const f of fondamentali) {
squadra[f.id].piu += c[f.id].piu;
squadra[f.id].meno += c[f.id].meno;
}
return { giocatoreId, conteggi: c };
});
return { squadra, perGiocatore };
}
/**
* I + di un giocatore in un set, esclusa la ricezione (che qui non vale come punto messo a
* terra): quello che compare nel suo riquadro dello scout.
*/
export function contaPiuSenzaRicezione(
valutazioni: Valutazione[],
giocatoreId: string,
set: number,
): number {
return valutazioni.filter(
(v) =>
v.giocatoreId === giocatoreId &&
v.set === set &&
v.segno === "+" &&
v.fondamentale !== "ricezione",
).length;
}
/**
* Parziali da archiviare: i set chiusi più il set in corso, se ha qualcosa dentro (punti o
* valutazioni). Senza punteggio un set vale 0-0: serve solo a numerarlo.
*/
export function parzialiFinali(
setChiusi: Array<[number, number]>,
corrente: [number, number],
haDati: boolean,
): Array<[number, number]> {
return haDati ? [...setChiusi, corrente] : setChiusi;
}
export type ScoutMatch = { export type ScoutMatch = {
id: string; id: string;
data: string; data: string;
@@ -69,6 +153,7 @@ export type ScoutMatch = {
setLoro: number; setLoro: number;
parziali: Array<[number, number]>; parziali: Array<[number, number]>;
azioni: Azione[]; azioni: Azione[];
valutazioni: Valutazione[];
}; };
type RigaScoutPartita = { type RigaScoutPartita = {
@@ -80,6 +165,7 @@ type RigaScoutPartita = {
set_loro: number; set_loro: number;
parziali: unknown; parziali: unknown;
azioni: unknown; azioni: unknown;
valutazioni: unknown;
}; };
function daRiga(r: RigaScoutPartita): ScoutMatch { function daRiga(r: RigaScoutPartita): ScoutMatch {
@@ -92,6 +178,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
setLoro: r.set_loro, setLoro: r.set_loro,
parziali: (r.parziali as Array<[number, number]> | null) ?? [], parziali: (r.parziali as Array<[number, number]> | null) ?? [],
azioni: (r.azioni as Azione[] | null) ?? [], azioni: (r.azioni as Azione[] | null) ?? [],
valutazioni: (r.valutazioni as Valutazione[] | null) ?? [],
}; };
} }
@@ -100,7 +187,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
async function fetchScoutMatches(): Promise<ScoutMatch[]> { async function fetchScoutMatches(): Promise<ScoutMatch[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from("scout_partite") .from("scout_partite")
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni")
.order("creato_il", { ascending: false }); .order("creato_il", { ascending: false });
if (error) throw error; if (error) throw error;
return (data ?? []).map(daRiga); return (data ?? []).map(daRiga);
@@ -131,6 +218,7 @@ export function useSalvaScoutMatch() {
set_loro: input.match.setLoro, set_loro: input.match.setLoro,
parziali: JSON.parse(JSON.stringify(input.match.parziali)), parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)), azioni: JSON.parse(JSON.stringify(input.match.azioni)),
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
}); });
if (error) throw error; if (error) throw error;
return input.match; return input.match;
@@ -159,6 +247,15 @@ export function useEliminaScoutMatch() {
}); });
} }
/**
* Una partita scoutata ha un risultato solo se ha registrato punti: quelle fatte di sole
* valutazioni − / + hanno parziali 0-0 e non devono sostituire il referto ufficiale né
* comparire come risultato in Home e Classifica.
*/
export function haPunteggio(match: Pick<ScoutMatch, "parziali">): boolean {
return match.parziali.some(([n, l]) => n + l > 0);
}
/** Somma delle azioni di un match per giocatore. */ /** Somma delle azioni di un match per giocatore. */
export function totaliPerGiocatore(azioni: Azione[]) { export function totaliPerGiocatore(azioni: Azione[]) {
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>(); const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
@@ -194,6 +291,46 @@ export function totaliSquadra(matches: ScoutMatch[]) {
return out; return out;
} }
export type TotaliTecnici = { punti: number; ace: number; attacchi: number; muri: number };
/**
* Somma, su tutte le partite scoutate, delle sole valutazioni **+** (battuta → ace, attacco →
* attacchi, muro → muri; punti = ace + attacchi + muri, non la ricezione). Con `giocatoreId`
* conta solo quel giocatore, altrimenti tutta la squadra. Una partita senza valutazioni (metodo
* precedente) conta con le sue azioni; se ha entrambe valgono solo le valutazioni, per non
* contare due volte.
*/
export function totaliTecnici(matches: ScoutMatch[], giocatoreId?: string): TotaliTecnici {
const out = { punti: 0, ace: 0, attacchi: 0, muri: 0 };
for (const m of matches) {
if (m.valutazioni.length === 0) {
for (const [id, t] of totaliPerGiocatore(m.azioni)) {
if (giocatoreId !== undefined && id !== giocatoreId) continue;
out.ace += t.ace;
out.muri += t.muri;
out.attacchi += t.punti - t.ace - t.muri;
out.punti += t.punti;
}
continue;
}
for (const v of m.valutazioni) {
if (v.segno !== "+" || v.fondamentale === "ricezione") continue;
if (giocatoreId !== undefined && v.giocatoreId !== giocatoreId) continue;
if (v.fondamentale === "battuta") out.ace += 1;
if (v.fondamentale === "attacco") out.attacchi += 1;
if (v.fondamentale === "muro") out.muri += 1;
out.punti += 1;
}
}
return out;
}
/** Punti, ace e muri di squadra per la tab Stats di `/squadra` (vedi `totaliTecnici`). */
export function totaliSquadraValutazioni(matches: ScoutMatch[]) {
const { punti, ace, muri } = totaliTecnici(matches);
return { punti, ace, muri };
}
/** /**
* MVP ricavati dalle partite scoutate (mappa matchId → nome vincitore). * MVP ricavati dalle partite scoutate (mappa matchId → nome vincitore).
* Le presenze personali restano su `risposte_presenze`, non sullo scout. * Le presenze personali restano su `risposte_presenze`, non sullo scout.
+3 -1
View File
@@ -33,7 +33,9 @@ async function leggiCsi(): Promise<DatiCsi> {
scarica(urlClassifica()), scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico // Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto). // come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""), CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()), scarica(urlPartite()),
]); ]);
const classifica = parseClassifica(html); const classifica = parseClassifica(html);
+2 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { formatData, type RigaClassifica } from "@/lib/crapp-data"; import { formatData, type RigaClassifica } from "@/lib/crapp-data";
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits"; import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { import {
isNostraSquadra, isNostraSquadra,
@@ -150,7 +150,7 @@ function Classifica() {
const m = matchDaPartitaCsi(p); const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false }; return { ...m, mvp: mvpPerData(m.data), scout: false };
}) })
: scoutMatches.map((m) => ({ : scoutMatches.filter(haPunteggio).map((m) => ({
id: m.id, id: m.id,
data: m.data, data: m.data,
avversario: m.avversario, avversario: m.avversario,
+4 -2
View File
@@ -19,7 +19,7 @@ import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreBase } from "@/lib/user-store";
import { useCsi } from "@/lib/csi"; 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 { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { mediaVotoColpoDOcchio } from "@/lib/pagelle"; import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
@@ -63,7 +63,9 @@ function Index() {
const votiMvp = useVotiMvp(); const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []); const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const csiGiocate = csi ? partiteGiocate(csi.partite) : []; const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null); const ultimaBase = csiGiocate[0]
? matchDaPartitaCsi(csiGiocate[0])
: (scoutMatches.find(haPunteggio) ?? null);
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null; const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
const eventoUltima = ultimaBase const eventoUltima = ultimaBase
+64 -90
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react"; import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Card, PageHeader, Section, SezioneTendina } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi"; import { convocatiEvento, useEvento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa"; import { useRosa } from "@/lib/rosa";
@@ -10,9 +10,10 @@ import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi"; 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 { sondaggioAperto } from "@/lib/cacche";
import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { ScoutReport } from "@/components/crapp/ScoutReport";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store"; import { useSonoAllenatore } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
@@ -70,26 +71,26 @@ function PartitaDetail() {
const csiMatch = csi const csiMatch = csi
? partiteGiocate(csi.partite).find((p) => p.data === evento.data) ? partiteGiocate(csi.partite).find((p) => p.data === evento.data)
: undefined; : undefined;
const match = scout const match =
? { scout && haPunteggio(scout)
id: scout.id, ? {
data: scout.data, id: scout.id,
avversario: scout.avversario, data: scout.data,
logoAvversario: "", avversario: scout.avversario,
casa: scout.casa, logoAvversario: "",
setNostri: scout.setNostri, casa: scout.casa,
setLoro: scout.setLoro, setNostri: scout.setNostri,
parziali: scout.parziali, setLoro: scout.setLoro,
campo: "", parziali: scout.parziali,
girone: "", campo: "",
numeroGara: "", girone: "",
arbitro: "", numeroGara: "",
link: "", arbitro: "",
} link: "",
: csiMatch }
? matchDaPartitaCsi(csiMatch) : csiMatch
: undefined; ? matchDaPartitaCsi(csiMatch)
const totaliTeam = scout ? totaliSquadra([scout]) : null; : undefined;
const avversario = evento.titolo.includes(" vs ") const avversario = evento.titolo.includes(" vs ")
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo) ? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
: evento.titolo; : evento.titolo;
@@ -205,9 +206,16 @@ function PartitaDetail() {
) : null} ) : null}
{csiMatch ? ( {csiMatch ? (
<Section titolo="Formazioni e scontri diretti"> <SezioneTendina
titolo="Formazioni e scontri diretti"
azione={
<span className="text-[13px] font-bold uppercase text-muted-foreground">
Livescore CSI
</span>
}
>
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} /> <DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
</Section> </SezioneTendina>
) : null} ) : null}
<Section titolo="MVP eletto dalla squadra"> <Section titolo="MVP eletto dalla squadra">
@@ -219,9 +227,9 @@ function PartitaDetail() {
{match ? ( {match ? (
// Badge e cacche non si mostrano all'allenatore (DD-034). // Badge e cacche non si mostrano all'allenatore (DD-034).
sonoAllenatore ? null : ( sonoAllenatore ? null : (
<Section titolo="Badge votati dai compagni"> <SezioneTendina titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} /> <VotoSocial matchId={match.id} />
</Section> </SezioneTendina>
) )
) : ( ) : (
<Section titolo="In programma"> <Section titolo="In programma">
@@ -237,83 +245,49 @@ function PartitaDetail() {
</Section> </Section>
{sonoAllenatore ? null : ( {sonoAllenatore ? null : (
<Section titolo="Sondaggio pre-partita"> <SezioneTendina
titolo="Sondaggio pre-partita"
defaultAperta={sondaggioAperto(evento.data, evento.ora)}
>
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} /> <SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section> </SezioneTendina>
)} )}
{match ? ( {match ? (
<Section titolo="Pagelle di fine partita"> <SezioneTendina titolo="Pagelle di fine partita">
<Pagelle matchId={match.id} convocati={convocati} chiuse={evento.pagelleChiuse} /> <Pagelle
</Section> matchId={match.id}
convocati={convocati.filter(
(g) => risposte[g.id] !== "assente" && risposte[g.id] !== "infortunato",
)}
chiuse={evento.pagelleChiuse}
/>
</SezioneTendina>
) : null} ) : null}
{scout && totaliTeam ? ( {scout ? (
<Section titolo="Report tecnico"> <SezioneTendina
<Card> titolo="Scout"
<div className="grid grid-cols-4 gap-2 text-center"> azione={
{[ scout.valutazioni.length > 0 ? (
{ l: "Punti", v: totaliTeam.punti }, <span className="text-[13px] font-bold tabular-nums text-muted-foreground">
{ l: "Ace", v: totaliTeam.ace }, {scout.valutazioni.length} valutazioni
{ l: "Muri", v: totaliTeam.muri }, </span>
{ l: "Errori", v: totaliTeam.errori }, ) : null
].map((t) => ( }
<div key={t.l} className="rounded-2xl bg-secondary p-2.5"> >
<p className="font-display text-xl leading-none">{t.v}</p> <ScoutReport scout={scout} rosa={rosa} admin={admin} />
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground"> </SezioneTendina>
{t.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Dettaglio giocatori (uso interno allenatori)
</p>
<div className="mt-2 space-y-1">
{[...totaliPerGiocatore(scout.azioni).entries()].map(([gid, t]) => {
const g = rosa.find((x) => x.id === gid);
if (!g) return null;
return (
<div
key={gid}
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
>
<span className="min-w-0 flex-1 truncate font-semibold">
#{g.numero} {g.nome}
</span>
<span className="tabular-nums text-muted-foreground">
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
</span>
</div>
);
})}
</div>
{admin ? (
<button
type="button"
onClick={() =>
scaricaCsv(
`scout-${scout.data}-${scout.avversario}.csv`,
csvScoutMatch(scout, rosa),
)
}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV
</button>
) : null}
</Card>
</Section>
) : null} ) : null}
<Section titolo="Rosa e presenze"> <SezioneTendina titolo="Rosa e presenze" defaultAperta>
<RosaPresenze <RosaPresenze
eventoId={evento.id} eventoId={evento.id}
data={evento.data} data={evento.data}
tipo={evento.tipo} tipo={evento.tipo}
convocati={evento.convocati} convocati={evento.convocati}
/> />
</Section> </SezioneTendina>
</> </>
); );
} }
+9 -2
View File
@@ -33,6 +33,7 @@ import {
type GiocatoreSquadra, type GiocatoreSquadra,
} from "@/lib/giocatori-squadra"; } from "@/lib/giocatori-squadra";
import { esci } from "@/lib/auth"; import { esci } from "@/lib/auth";
import { totaliTecnici, useScoutMatches } from "@/lib/scout-store";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useEmailNotifiche } from "@/lib/preferenze-utente"; import { useEmailNotifiche } from "@/lib/preferenze-utente";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
@@ -80,9 +81,12 @@ function ProfiloGiocatore() {
const votiSocial = useVotiSocial(); const votiSocial = useVotiSocial();
const g = useIo(); const g = useIo();
const ultimoMese = usePresenzeUltimoMese(g?.id); const ultimoMese = usePresenzeUltimoMese(g?.id);
const scoutMatches = useScoutMatches();
if (!g) return null; if (!g) return null;
const tecnici = totaliTecnici(scoutMatches, g.id);
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0; const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
return ( return (
@@ -122,9 +126,12 @@ function ProfiloGiocatore() {
} }
label="Presenze di fila" label="Presenze di fila"
/> />
<StatTile valore={g.mediaVoto || "—"} label="Media voto" /> <StatTile valore={tecnici.attacchi} label="Attacchi" />
<StatTile valore={tecnici.muri} label="Muri" />
<StatTile valore={tecnici.ace} label="Ace" />
<StatTile valore={tecnici.punti} label="Punti" hint="totale" />
<StatTile valore={g.mvp} label="MVP" /> <StatTile valore={g.mvp} label="MVP" />
<StatTile valore={g.palloni} label="Turni palloni" /> <StatTile valore={g.mediaVoto || "—"} label="Media voto" />
</div> </div>
<SerieGriglia g={g} /> <SerieGriglia g={g} />
</div> </div>
+137 -169
View File
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Select } 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 { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
@@ -27,10 +27,15 @@ import {
} from "@/lib/scout-live"; } from "@/lib/scout-live";
import { import {
azioniMeta, azioniMeta,
contaPiuSenzaRicezione,
contaValutazioni,
parzialiFinali,
fondamentali,
useSalvaScoutMatch, useSalvaScoutMatch,
totaliPerGiocatore,
type Azione, type Azione,
type AzioneTipo, type AzioneTipo,
type Fondamentale,
type Valutazione,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({ export const Route = createFileRoute("/scout")({
@@ -52,8 +57,6 @@ export const Route = createFileRoute("/scout")({
component: Scout, component: Scout,
}); });
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({ function Blocco({
icona, icona,
titolo, titolo,
@@ -119,7 +122,11 @@ function Scout() {
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null; const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id; const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id;
const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0; const ripresa =
!!statoSalvato.data &&
(statoSalvato.data.azioni.length > 0 ||
(statoSalvato.data.valutazioni?.length ?? 0) > 0 ||
statoSalvato.data.setChiusi.length > 0);
if (!controllo) { if (!controllo) {
return ( return (
@@ -196,10 +203,12 @@ function ScoutBoard({
.trim() || "Avversario", .trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"), partita.titolo.trim().toLowerCase().startsWith("crap"),
); );
const [avversario, setAvversario] = useState(base.avversario); const [avversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa); const [casa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi); const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
const [azioni, setAzioni] = useState<Azione[]>(base.azioni); const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
const [valutazioni, setValutazioni] = useState<Valutazione[]>(base.valutazioni ?? []);
const [ordine, setOrdine] = useState<"cognome" | "numero">("cognome");
const [selezionato, setSelezionato] = useState<string | null>(null); const [selezionato, setSelezionato] = useState<string | null>(null);
const salva = useSalvaStatoScout(); const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout(); const cancella = useCancellaStatoScout();
@@ -208,52 +217,73 @@ function ScoutBoard({
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra. // Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
const rosa = useAnagraficaRosa(); const rosa = useAnagraficaRosa();
const finito = useRef(false); const finito = useRef(false);
const codaSalvataggio = useRef<Promise<unknown>>(Promise.resolve());
/** In campo solo chi ha confermato la presenza (anche in ritardo). */ /** In campo solo chi ha confermato la presenza (anche in ritardo). */
const convocati = useMemo(() => { const convocati = useMemo(() => {
const presenti = rosa.filter( const presenti = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
); );
return presenti.length > 0 ? presenti : rosa; const elenco = presenti.length > 0 ? presenti : rosa;
}, [risposte, rosa]); return [...elenco].sort((a, b) =>
ordine === "numero"
? a.numero - b.numero
: a.cognome.localeCompare(b.cognome, "it") || a.numero - b.numero,
);
}, [risposte, rosa, ordine]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui. // Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => { useEffect(() => {
if (finito.current) return; if (finito.current) return;
const id = window.setTimeout(() => { // Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si
void salva.mutateAsync({ // chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito
eventoId: partita.id, // rimette a posto tutto; la coda li tiene in ordine.
stato: { azioni, setChiusi, avversario, casa }, const stato = { azioni, valutazioni, setChiusi, avversario, casa };
codaSalvataggio.current = codaSalvataggio.current
.then(() => salva.mutateAsync({ eventoId: partita.id, stato }))
.then(() => {
toast.dismiss("scout-salvataggio");
})
.catch(() => {
toast.error("Salvataggio non riuscito: controlla la connessione", {
id: "scout-salvataggio",
duration: Infinity,
});
}); });
}, 800);
return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [azioni, setChiusi, avversario, casa, partita.id]); }, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]);
const setCorrente = setChiusi.length + 1; const setCorrente = setChiusi.length + 1;
const azioniSet = azioni.filter((a) => a.set === setCorrente); const azioniSet = azioni.filter((a) => a.set === setCorrente);
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
const puntiLoro = azioniSet.length - puntiNoi; const puntiLoro = azioniSet.length - puntiNoi;
const setNostri = setChiusi.filter(([n, l]) => n > l).length; const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
const setLoro = setChiusi.length - setNostri;
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
function registra(tipo: AzioneTipo) { function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
const meta = azioniMeta[tipo]; if (!selezionato) {
if (meta.richiedeGiocatore && !selezionato) {
toast.error("Seleziona prima il giocatore"); toast.error("Seleziona prima il giocatore");
return; return;
} }
setAzioni((prev) => { setValutazioni((prev) => [
const nuova: Azione = { ...prev,
{
id: `${Date.now()}-${prev.length}`, id: `${Date.now()}-${prev.length}`,
tipo, giocatoreId: selezionato,
fondamentale,
segno,
set: setCorrente, set: setCorrente,
ts: Date.now(), ts: Date.now(),
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}), },
}; ]);
return [...prev, nuova]; }
});
function annullaValutazione() {
const ultima = valutazioni[valutazioni.length - 1];
if (!ultima || ultima.set !== setCorrente) {
toast.error("Puoi annullare solo le valutazioni del set in corso");
return;
}
setValutazioni((prev) => prev.slice(0, -1));
} }
function annulla() { function annulla() {
@@ -266,17 +296,16 @@ function ScoutBoard({
} }
function chiudiSet() { function chiudiSet() {
if (puntiNoi === 0 && puntiLoro === 0) { if (!setHaDati) {
toast.error("Set senza azioni"); toast.error("Set senza valutazioni");
return; return;
} }
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]); setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`); toast.success(`Set ${setCorrente} chiuso`);
} }
async function finePartita() { async function finePartita() {
const parziali: Array<[number, number]> = const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
if (parziali.length === 0) { if (parziali.length === 0) {
toast.error("Nessun set da salvare"); toast.error("Nessun set da salvare");
return; return;
@@ -294,21 +323,24 @@ function ScoutBoard({
setLoro: parziali.length - vinti, setLoro: parziali.length - vinti,
parziali, parziali,
azioni, azioni,
valutazioni,
}, },
}); });
} catch { } catch {
toast.error("Salvataggio non riuscito, riprova"); toast.error("Salvataggio non riuscito, riprova");
return; return;
} }
toast.success("Partita salvata: ora la squadra può votare l'MVP"); toast.success("Partita salvata");
finito.current = true; finito.current = true;
void cancella.mutateAsync(partita.id); void cancella.mutateAsync(partita.id);
onFine(); onFine();
navigate({ to: "/squadra" }); navigate({ to: "/squadra" });
} }
const ultime = [...azioni].slice(-4).reverse(); const giocatoreSel = rosa.find((g) => g.id === selezionato);
const contiSelezionato = selezionato
? contaValutazioni(valutazioni, selezionato, setCorrente)
: null;
return ( return (
<> <>
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground"> <header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
@@ -334,64 +366,49 @@ function ScoutBoard({
</button> </button>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<div className="min-w-0">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
{avversario || "Avversario"}
</p>
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
</div>
</div>
{setChiusi.length > 0 ? (
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
{setChiusi.map((p, i) => (
<span
key={i}
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[13px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
) : null}
</header> </header>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <div className="mb-2 flex items-center justify-between gap-2">
1. Tocca il giocatore <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
</p> Selezione giocatore
<div className="grid grid-cols-4 gap-2"> </p>
<div className="w-40 shrink-0">
<Select
value={ordine}
onChange={(e) => setOrdine(e.target.value as "cognome" | "numero")}
aria-label="Ordina i giocatori"
>
<option value="cognome">Per cognome</option>
<option value="numero">Per numero</option>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-2 min-[400px]:grid-cols-4">
{convocati.map((g) => { {convocati.map((g) => {
const attivo = selezionato === g.id; const attivo = selezionato === g.id;
const t = totali.get(g.id); const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
return ( return (
<button <button
key={g.id} key={g.id}
type="button" type="button"
onClick={() => setSelezionato(attivo ? null : g.id)} onClick={() => setSelezionato(attivo ? null : g.id)}
className={cn( className={cn(
"rounded-2xl p-2 text-center transition-all active:scale-95", "rounded-2xl p-1.5 text-center transition-all active:scale-95",
attivo ? "bg-accent text-accent-foreground shadow-pop" : "bg-card shadow-card", attivo ? "bg-accent text-accent-foreground shadow-pop" : "bg-card shadow-card",
)} )}
> >
<p className="font-display text-2xl leading-none">{g.numero}</p> <p className="font-display text-2xl leading-none">{g.numero}</p>
<p className="mt-1 text-[13px] font-bold leading-tight">{g.nome}</p> <p className="mt-1 text-[13px] font-bold leading-tight [overflow-wrap:normal]">
{g.nome}
</p>
<p <p
className={cn( className={cn(
"text-[13px] tabular-nums", "text-[13px] tabular-nums",
attivo ? "text-accent-foreground/70" : "text-muted-foreground", attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)} )}
> >
{t ? `${t.punti}p` : "0p"} +{piu}
</p> </p>
</button> </button>
); );
@@ -400,105 +417,56 @@ function ScoutBoard({
</section> </section>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <div className="mb-2 flex items-center justify-between gap-2">
2. Tocca l'azione
</p>
<div className="grid grid-cols-2 gap-2">
{ordineAzioni.map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
!selezionato && "opacity-50",
)}
>
{azioniMeta[tipo].label}
</button>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
)}
>
{azioniMeta[tipo].short}
</button>
))}
</div>
</section>
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-3 shadow-card">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
{setCorrente}
</p> </p>
{ultime.length === 0 ? ( <button
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p> type="button"
) : ( onClick={annullaValutazione}
<ul className="mt-2 space-y-1.5"> disabled={!valutazioni.some((v) => v.set === setCorrente)}
{ultime.map((a, i) => { className="inline-flex shrink-0 items-center gap-1 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
const g = rosa.find((x) => x.id === a.giocatoreId); >
return ( <Undo2 className="h-3.5 w-3.5" /> Annulla
<li </button>
key={a.id} </div>
className={cn( <div
"flex items-center justify-between gap-2 px-1 text-[13px]", className={cn(
i === 0 && "anim-riga", "space-y-2 rounded-3xl bg-card p-3 shadow-card",
)} !selezionato && "opacity-60",
>
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[13px] font-bold uppercase",
azioniMeta[a.tipo].className,
)}
>
{azioniMeta[a.tipo].short}
</span>
</li>
);
})}
</ul>
)} )}
>
{!selezionato ? (
<p className="text-[13px] text-muted-foreground">
Tocca un giocatore: i + e i − non cambiano il punteggio.
</p>
) : null}
{fondamentali.map((f) => {
const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 };
return (
<div key={f.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-2">
<span className="text-[15px] font-bold">{f.label}</span>
<button
type="button"
aria-label={`${f.label}: meno`}
onClick={() => valuta(f.id, "-")}
className="min-w-16 rounded-2xl bg-destructive px-4 py-2.5 font-display text-xl leading-none text-destructive-foreground shadow-card transition-transform active:scale-95"
>
− <span className="tabular-nums">{c.meno}</span>
</button>
<button
type="button"
aria-label={`${f.label}: più`}
onClick={() => valuta(f.id, "+")}
className="min-w-16 rounded-2xl bg-success px-4 py-2.5 font-display text-xl leading-none text-success-foreground shadow-card transition-transform active:scale-95"
>
+ <span className="tabular-nums">{c.piu}</span>
</button>
</div>
);
})}
</div> </div>
</section>
<section className="px-5 pt-4">
<Card>
<label className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Avversario
</label>
<input
value={avversario}
maxLength={40}
onChange={(e) => setAvversario(e.target.value)}
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]"
/>
<div className="mt-3 flex rounded-full bg-secondary p-1">
{[true, false].map((v) => (
<button
key={String(v)}
type="button"
onClick={() => setCasa(v)}
className={cn(
"flex-1 rounded-full py-1.5 text-[13px] font-bold uppercase",
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
)}
>
{v ? "In casa" : "Trasferta"}
</button>
))}
</div>
</Card>
</section> </section>
<section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4"> <section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
+2 -2
View File
@@ -23,7 +23,7 @@ import {
useGiocatoriSquadra, useGiocatoriSquadra,
} from "@/lib/giocatori-squadra"; } from "@/lib/giocatori-squadra";
import { useSonoAllenatore } from "@/lib/user-store"; import { useSonoAllenatore } from "@/lib/user-store";
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store"; import { totaliSquadraValutazioni, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { partiteGiocate } from "@/lib/csi-core"; import { partiteGiocate } from "@/lib/csi-core";
import { mediaSquadra, usePagelle } from "@/lib/pagelle"; import { mediaSquadra, usePagelle } from "@/lib/pagelle";
@@ -110,7 +110,7 @@ function Squadra() {
const [criterio, setCriterio] = useState<Criterio>("presenze"); const [criterio, setCriterio] = useState<Criterio>("presenze");
const [filtroAperto, setFiltroAperto] = useState(false); const [filtroAperto, setFiltroAperto] = useState(false);
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const team = totaliSquadra(scoutMatches); const team = totaliSquadraValutazioni(scoutMatches);
const mediaPresenze = rosa.length const mediaPresenze = rosa.length
? Math.round( ? Math.round(
(rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100, (rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100,
+20
View File
@@ -520,11 +520,31 @@
border-color: var(--color-border); border-color: var(--color-border);
} }
/*
* Mai scroll orizzontale della pagina (iPhone 360-402 px). `clip` e non `hidden`:
* non crea un contenitore di scroll, quindi `position: sticky` continua a funzionare.
*/
html,
body {
max-width: 100%;
overflow-x: clip;
}
/*
* Gli elementi di una griglia hanno `min-width: auto`: una colonna `1fr` non scende sotto
* la parola più lunga e sfonda la riga. Con `min-width: 0` la colonna resta nel suo posto
* e `overflow-wrap` spezza solo la parola che davvero non ci sta.
*/
.grid > * {
min-width: 0;
}
body { body {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
line-height: 1.5; line-height: 1.5;
overflow-wrap: break-word;
font-optical-sizing: auto; font-optical-sizing: auto;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
/* Niente pull-to-refresh accidentale né rimbalzo che scopre il fondo. */ /* Niente pull-to-refresh accidentale né rimbalzo che scopre il fondo. */
@@ -0,0 +1,10 @@
-- M39 — Scout Live: valutazioni − / + per fondamentale.
--
-- Durante la partita lo scout può segnare, per ogni giocatore e per ogni set, un − o un +
-- su battuta, ricezione, attacco e muro. Non sono punti: restano fuori dal punteggio e da
-- `azioni`. Lo stato in corso le porta dentro `scout_live.stato` (jsonb, nessuna modifica);
-- la partita archiviata le conserva in questa colonna, accanto alle azioni.
-- Solo additiva: le partite già salvate ricevono l'elenco vuoto.
ALTER TABLE public.scout_partite
ADD COLUMN valutazioni jsonb NOT NULL DEFAULT '[]'::jsonb;
+2 -4
View File
@@ -62,10 +62,8 @@ try {
const csi = (await json(res)) as DatiCsi; const csi = (await json(res)) as DatiCsi;
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra)); const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale"); assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok( // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata.
csi.partite.some((p) => p.setNostri !== null), assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale");
"ci sono risultati giocati",
);
const html = await pagina("/classifica"); const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/); assert.match(titolo(html), /Classifica campionato/);
+8 -5
View File
@@ -124,9 +124,13 @@ try {
assert.ok(Date.now() - t0 < 2_000, "risposta immediata"); assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
}); });
await provaCsi( // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti", // del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile.
async () => { const nomeDettaglio = "GET /api/public/csi-partita/$id restituisce formazioni e precedenti";
if (csiRaggiungibile && !partiteGiocate(csi?.partite ?? [])[0]) {
salta(nomeDettaglio, "nessuna gara ancora giocata nella stagione");
} else
await provaCsi(nomeDettaglio, async () => {
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0]; const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio"); assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
@@ -147,8 +151,7 @@ try {
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(secondo.status, 200); assert.equal(secondo.status, 200);
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata"); assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
}, });
);
await provaCsi( await provaCsi(
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe", "GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
+27
View File
@@ -32,6 +32,7 @@ if (!locale) {
"turni_palloni", "turni_palloni",
"risposte_presenze", "risposte_presenze",
"scout_live", "scout_live",
"scout_partite",
] as const; ] as const;
const rest = (percorso: string, init?: RequestInit) => const rest = (percorso: string, init?: RequestInit) =>
@@ -312,6 +313,32 @@ if (!locale) {
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi", "il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
); );
}); });
// --- scout: le valutazioni − / + stanno accanto alle azioni (m39) ----------------
await prova(
"la partita scoutata conserva le valutazioni; senza, l'elenco è vuoto",
async () => {
const evento = `${PREFISSO}-e5`;
const base = {
evento_id: evento,
data: "2026-11-02",
avversario: "X",
set_nostri: 3,
set_loro: 0,
};
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s1` });
const valutazioni = [
{ id: "v1", giocatoreId: "g1", fondamentale: "battuta", segno: "+", set: 1, ts: 0 },
];
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s2`, valutazioni });
const righe = await leggi(
"scout_partite",
`evento_id=eq.${evento}&select=id,valutazioni&order=id`,
);
assert.deepEqual(righe[0]?.["valutazioni"], [], "le partite senza valutazioni hanno []");
assert.deepEqual(righe[1]?.["valutazioni"], valutazioni);
},
);
} finally { } finally {
for (const tabella of TABELLE) { for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id"; const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
+34 -23
View File
@@ -333,42 +333,51 @@ assert.equal(
if (process.env["CSI_LIVE"]) { if (process.env["CSI_LIVE"]) {
const [html, htmlCoppa, 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()), CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: 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 >= 7); // a inizio stagione le giocate sono ancora 0
const liveCoppa = parseClassifica(htmlCoppa); const liveCoppa = parseClassifica(htmlCoppa);
assert.ok( if (CSI_COPPA_PROJECT_ID !== null) {
liveCoppa.some((r) => isNostraSquadra(r.squadra)), assert.ok(
"la nostra squadra è nel girone di Coppa", liveCoppa.some((r) => isNostraSquadra(r.squadra)),
); "la nostra squadra è nel girone di Coppa",
);
}
const livePartite = partiteDaEventi(json); const livePartite = partiteDaEventi(json);
const liveGiocate = partiteGiocate(livePartite); const liveGiocate = partiteGiocate(livePartite);
assert.ok(livePartite.length > 0 && liveGiocate.length > 0); assert.ok(livePartite.length > 0, "calendario presente"); // liveGiocate può essere vuoto a inizio stagione
assert.ok( assert.ok(
liveGiocate.every((p) => p.link.startsWith("https://")), liveGiocate.every((p) => p.link.startsWith("https://")),
"ogni partita ha un referto", "ogni partita ha un referto",
); );
const primaGiocata = liveGiocate[0]!; // A inizio stagione nessuna gara è ancora giocata: niente referto da leggere.
const [liveMain, livePlayers, liveStats] = await Promise.all([ if (liveGiocate.length === 0) {
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()), console.log(`live: ${live.length} squadre, ${livePartite.length} partite, nessuna giocata`);
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()), } else {
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()), const primaGiocata = liveGiocate[0]!;
]); const [liveMain, livePlayers, liveStats] = await Promise.all([
const liveFormazioni = parseFormazioni(livePlayers); fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata"); fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra)); fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
const livePrecedenti = parsePrecedenti(liveStats); ]);
assert.ok(livePrecedenti, "precedenti riconosciuti"); const liveFormazioni = parseFormazioni(livePlayers);
console.log( assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` + assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` + const livePrecedenti = parsePrecedenti(liveStats);
`formazione ${liveFormazioni!.noi.titolari.length} titolari`, 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`,
);
}
} }
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------ // --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
@@ -377,6 +386,8 @@ assert.equal(stagioneDi("2026-05-20"), "2025/26");
assert.equal(stagioneDi("2026-07-31"), "2025/26"); assert.equal(stagioneDi("2026-07-31"), "2025/26");
assert.equal(stagioneDi("2026-08-01"), "2026/27"); assert.equal(stagioneDi("2026-08-01"), "2026/27");
assert.equal(stagioneDi("2099-10-01"), "2099/00"); assert.equal(stagioneDi("2099-10-01"), "2099/00");
assert.equal(stagioneDi("2026-11-04"), "2026/27", "prima gara del campionato 2026/27");
assert.equal(stagioneDi("2027-03-11"), "2026/27", "ultima gara del campionato 2026/27");
assert.equal(stagioneDaPartite([]), null); assert.equal(stagioneDaPartite([]), null);
assert.equal( assert.equal(
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]), stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
+33
View File
@@ -0,0 +1,33 @@
/**
* Check dei requisiti di layout per schermi stretti (360 px): `bun test/unit/layout-stretto.test.ts`.
*
* Non c'è un driver browser (vedi `test/README.md`), quindi si presidiano le due regole che,
* se tolte, fanno tornare la pagina tagliata in larghezza su iPhone: i campi a 16 px (sotto,
* iOS zooma al focus e non torna indietro) e la rete di sicurezza sull'overflow orizzontale.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { classiInput } from "@/components/crapp/ui-bits";
assert.match(classiInput, /\btext-base\b/, "i campi devono essere a 16px (zoom automatico iOS)");
assert.doesNotMatch(classiInput, /text-\[1[0-5]px\]|text-sm|text-xs/);
const css = readFileSync(new URL("../../src/styles.css", import.meta.url), "utf8");
assert.match(css, /overflow-x:\s*clip/, "html/body devono impedire lo scroll orizzontale");
assert.match(
css,
/\.grid\s*>\s*\*\s*\{\s*min-width:\s*0/,
"gli elementi di griglia devono poter restringersi",
);
const scout = readFileSync(
new URL("../../src/components/crapp/ScoutReport.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(
scout,
/min-w-\[\d+rem\]/,
"la tabella scout non deve imporre una larghezza minima",
);
console.log("layout-stretto: ok");
+40
View File
@@ -29,6 +29,7 @@ const match: ScoutMatch = {
[25, 18], [25, 18],
], ],
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")], azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
valutazioni: [],
}; };
const csv = csvScoutMatch(match, giocatori); const csv = csvScoutMatch(match, giocatori);
@@ -78,3 +79,42 @@ const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }, giocat
assert.ok(conVirgolette.includes('"Team ""X"""')); assert.ok(conVirgolette.includes('"Team ""X"""'));
console.log("scout-export: ok"); console.log("scout-export: ok");
// --- valutazioni − / + per set -------------------------------------------------
assert.ok(!csv.includes("Battuta +"), "senza valutazioni la sezione non c'è");
const val = (
giocatoreId: string,
fondamentale: "battuta" | "muro",
segno: "+" | "-",
set: number,
) => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
const conVal = csvScoutMatch(
{
...match,
valutazioni: [
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "-", 1),
val(g1.id, "muro", "-", 2),
],
},
giocatori,
);
assert.ok(
conVal.includes(
"Set;Numero;Giocatore;Battuta +;Battuta -;Ricezione +;Ricezione -;Attacco +;Attacco -;Muro +;Muro -",
),
);
assert.ok(
conVal.includes(`1;${g1.numero};${g1.nome};2;1;0;0;0;0;0;0`),
"set 1: 2 più e 1 meno in battuta",
);
assert.ok(conVal.includes(`2;${g1.numero};${g1.nome};0;0;0;0;0;0;0;1`), "set 2: 1 meno a muro");
assert.ok(!conVal.includes(`;${g2.nome};0;0;0;0;0;0;0;0`), "chi non ha valutazioni non ha riga");
+5 -1
View File
@@ -5,7 +5,10 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati ----------- // --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
const stato = statoIniziale("Avversari", true); const stato = statoIniziale("Avversari", true);
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`. // Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true }); assert.deepEqual(
{ ...stato },
{ azioni: [], valutazioni: [], setChiusi: [], avversario: "Avversari", casa: true },
);
const trasferta = statoIniziale("Altra Squadra", false); const trasferta = statoIniziale("Altra Squadra", false);
assert.equal(trasferta.casa, false); assert.equal(trasferta.casa, false);
@@ -14,6 +17,7 @@ assert.deepEqual(trasferta.azioni, [], "ogni chiamata parte da una lista vuota i
// Le liste non devono essere condivise tra due stati distinti. // Le liste non devono essere condivise tra due stati distinti.
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 }); stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro"); assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro");
assert.notEqual(stato.valutazioni, trasferta.valutazioni, "anche le valutazioni sono indipendenti");
// --- SCOUT_STATO_KEY: chiave stabile per React Query -------------------------- // --- SCOUT_STATO_KEY: chiave stabile per React Query --------------------------
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]); assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
+186
View File
@@ -3,10 +3,19 @@ import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data"; import { giocatori } from "@/lib/crapp-data";
import { import {
azioniMeta, azioniMeta,
contaPiuSenzaRicezione,
haPunteggio,
parzialiFinali,
sintesiValutazioni,
contaValutazioni,
fondamentali,
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
totaliSquadraValutazioni,
totaliTecnici,
type Azione, type Azione,
type Valutazione,
type ScoutMatch, type ScoutMatch,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
@@ -32,6 +41,7 @@ const match = (
setLoro, setLoro,
parziali: [], parziali: [],
azioni, azioni,
valutazioni: [],
}); });
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------ // --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
@@ -59,6 +69,60 @@ assert.deepEqual(totaliSquadra([match("m1", 3, 0, azioni), match("m2", 3, 1, azi
}); });
assert.deepEqual(totaliSquadra([]), { punti: 0, ace: 0, muri: 0, errori: 0 }); assert.deepEqual(totaliSquadra([]), { punti: 0, ace: 0, muri: 0, errori: 0 });
// --- totaliSquadraValutazioni: solo i + dello scout, fallback sulle azioni ------
const vl = (fondamentale: Valutazione["fondamentale"], segno: "+" | "-", giocatoreId = "g1") =>
({
id: Math.random().toString(),
giocatoreId,
fondamentale,
segno,
set: 1,
ts: 0,
}) as Valutazione;
const conVal = {
...match("v1", 3, 0),
valutazioni: [
vl("battuta", "+"),
vl("battuta", "+", "g2"),
vl("battuta", "-"),
vl("muro", "+"),
vl("muro", "-"),
vl("attacco", "+"),
vl("attacco", "+", "g2"),
vl("attacco", "-"),
vl("ricezione", "+"),
vl("ricezione", "+"),
],
};
assert.deepEqual(totaliSquadraValutazioni([conVal]), { punti: 5, ace: 2, muri: 1 });
assert.deepEqual(
totaliSquadraValutazioni([conVal, conVal]),
{ punti: 10, ace: 4, muri: 2 },
"somma su più partite",
);
assert.deepEqual(
totaliSquadraValutazioni([match("a1", 3, 0, azioni)]),
{ punti: 4, ace: 1, muri: 1 },
"senza valutazioni: contano le azioni del metodo precedente",
);
assert.deepEqual(
totaliSquadraValutazioni([{ ...conVal, azioni }]),
{ punti: 5, ace: 2, muri: 1 },
"con entrambe valgono solo le valutazioni",
);
assert.deepEqual(totaliSquadraValutazioni([]), { punti: 0, ace: 0, muri: 0 });
// --- totaliTecnici: per giocatore, con attacchi -------------------------------
assert.deepEqual(totaliTecnici([conVal], "g1"), { punti: 3, ace: 1, attacchi: 1, muri: 1 });
assert.deepEqual(totaliTecnici([conVal], "g2"), { punti: 2, ace: 1, attacchi: 1, muri: 0 });
assert.deepEqual(totaliTecnici([conVal], "assente"), { punti: 0, ace: 0, attacchi: 0, muri: 0 });
assert.deepEqual(totaliTecnici([conVal, conVal]), { punti: 10, ace: 4, attacchi: 4, muri: 2 });
assert.deepEqual(
totaliTecnici([match("a1", 3, 0, azioni)], "g1"),
{ punti: 3, ace: 1, attacchi: 1, muri: 1 },
"metodo precedente: ace, muro e attacco dalle azioni",
);
// --- giocatoriConScout: solo MVP da scout, presenze separate ----------------- // --- giocatoriConScout: solo MVP da scout, presenze separate -----------------
const base = giocatori.find((g) => g.id === "g1")!; const base = giocatori.find((g) => g.id === "g1")!;
const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome }); const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome });
@@ -84,3 +148,125 @@ for (const [tipo, meta] of Object.entries(azioniMeta)) {
} }
console.log("scout-store: ok"); console.log("scout-store: ok");
// --- valutazioni − / +: per giocatore, fondamentale e set; fuori dal punteggio -------
const v = (
giocatoreId: string,
fondamentale: Valutazione["fondamentale"],
segno: Valutazione["segno"],
set = 1,
): Valutazione => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
assert.deepEqual(
fondamentali.map((f) => f.id),
["battuta", "ricezione", "attacco", "muro"],
);
const val = [
v("g1", "battuta", "+"),
v("g1", "battuta", "+"),
v("g1", "battuta", "-"),
v("g1", "ricezione", "-", 2),
v("g1", "muro", "+", 2),
v("g2", "attacco", "+"),
];
assert.deepEqual(contaValutazioni(val, "g1", 1), {
battuta: { piu: 2, meno: 1 },
ricezione: { piu: 0, meno: 0 },
attacco: { piu: 0, meno: 0 },
muro: { piu: 0, meno: 0 },
});
assert.deepEqual(
contaValutazioni(val, "g1", 2).ricezione,
{ piu: 0, meno: 1 },
"il set 2 è separato",
);
assert.deepEqual(
contaValutazioni(val, "g1").battuta,
{ piu: 2, meno: 1 },
"senza set: tutta la partita",
);
assert.deepEqual(contaValutazioni(val, "g1").ricezione, { piu: 0, meno: 1 });
assert.deepEqual(
contaValutazioni(val, "g3", 1).battuta,
{ piu: 0, meno: 0 },
"giocatore senza valutazioni",
);
assert.equal(totaliPerGiocatore([]).size, 0, "le valutazioni non sono azioni: nessun punto");
// --- + del riquadro giocatore: tutti i fondamentali tranne la ricezione, nel set -------
const piuVal = [
v("g1", "battuta", "+"),
v("g1", "attacco", "+"),
v("g1", "muro", "+"),
v("g1", "ricezione", "+"),
v("g1", "attacco", "-"),
v("g1", "battuta", "+", 2),
v("g2", "attacco", "+"),
];
assert.equal(
contaPiuSenzaRicezione(piuVal, "g1", 1),
3,
"niente ricezione, niente −, solo il set 1",
);
assert.equal(contaPiuSenzaRicezione(piuVal, "g1", 2), 1);
assert.equal(contaPiuSenzaRicezione(piuVal, "g3", 1), 0);
// --- parziali finali: il set in corso conta solo se ha dati ------------------------
assert.deepEqual(
parzialiFinali([[25, 20]], [0, 0], true),
[
[25, 20],
[0, 0],
],
"set con sole valutazioni",
);
assert.deepEqual(
parzialiFinali([[25, 20]], [0, 0], false),
[[25, 20]],
"set vuoto: non si archivia",
);
assert.deepEqual(parzialiFinali([], [0, 0], false), [], "nessun set da salvare");
// --- haPunteggio: le partite di sole valutazioni non sono un risultato -------------
assert.equal(
haPunteggio({
parziali: [
[0, 0],
[0, 0],
],
}),
false,
);
assert.equal(haPunteggio({ parziali: [] }), false);
assert.equal(
haPunteggio({
parziali: [
[0, 0],
[25, 18],
],
}),
true,
);
// --- sintesi per la lettura nell'app: squadra e giocatori, partita o singolo set -----
const tutta = sintesiValutazioni(val);
assert.deepEqual(tutta.squadra.battuta, { piu: 2, meno: 1 });
assert.deepEqual(tutta.squadra.attacco, { piu: 1, meno: 0 });
assert.deepEqual(tutta.squadra.ricezione, { piu: 0, meno: 1 });
assert.deepEqual(tutta.perGiocatore.map((r) => r.giocatoreId).sort(), ["g1", "g2"]);
const set2 = sintesiValutazioni(val, 2);
assert.deepEqual(
set2.perGiocatore.map((r) => r.giocatoreId),
["g1"],
);
assert.deepEqual(set2.squadra.muro, { piu: 1, meno: 0 });
assert.deepEqual(set2.squadra.battuta, { piu: 0, meno: 0 });
assert.equal(sintesiValutazioni([]).perGiocatore.length, 0);
console.log("scout-store sintesi: ok");