16 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
22 changed files with 744 additions and 187 deletions

No files matched your search

+14 -16
View File
@@ -9,9 +9,13 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.5.0 su `main` (tag `v1.5.0`): campionato CSI sulla stagione 2026/27 (nuovi `project_id` - **Versione:** 1.6.2 su `main`: layout corretto sugli schermi stretti (360-402 px) e
e `team_id`, nessuna Coppa). Su `feature/gara` c'è del lavoro non ancora rilasciato (voto MVP, vedi Ace, Attacchi, Muri e Punti dai + dello scout in Squadra e Profilo, senza migration. La 1.6.1
«Lavoro in corso»). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come (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 «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
@@ -21,25 +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:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione - **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
sono applicate 45 su 50, 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`-`m39`, nessuna `npx supabase migration list --linked`; nessuna migration remota è assente dal repository). Prima
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m39`** del push è stato salvato lo schema di produzione (solo schema, non i dati).
(voto MVP e badge social, DD-051 e DD-052, valutazioni dello scout, DD-053; 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). Poi lo scout live ridotto alle valutazioni − / + si creano più, quindi è sempre spento.
per set (DD-053), salvate subito su database. Non ancora in `main`, non rilasciato, versione
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
## Cose da sapere ## Cose da sapere
+55 -3
View File
@@ -13,7 +13,54 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
Cinque migration, da `m35` a `m39`, **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
- 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 ### Aggiunto
@@ -23,14 +70,16 @@ Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**.
### Modificato ### Modificato
- Scout live: tolti i tasti dei punti, il tabellone del punteggio e il blocco avversario / in casa / - Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
trasferta; resta la scelta del giocatore e la valutazione − / + per set 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 ([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. 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, - Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
se non riesce, compare un avviso. se non riesce, compare un avviso.
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di - 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. 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
@@ -41,6 +90,9 @@ Cinque migration, da `m35` a `m39`, **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
+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
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.
+3 -2
View File
@@ -230,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.
@@ -243,7 +244,7 @@ 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` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi 2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano 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.
+39 -27
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`
@@ -11,8 +11,8 @@
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,
un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato, un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato,
per set, con salvataggio condiviso su 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,10 +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,
valutazioni − / +), valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero).
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`).
@@ -66,14 +65,20 @@ perché occupava spazio 6 giorni su 7.
## Cosa registra ## Cosa registra
La schermata `/scout` ha due passi: **Selezione giocatore** (griglia dei convocati, ordinata per cognome o per numero con il selettore a destra del titolo, default cognome; in ogni riquadro, sotto il nome, i **+** del giocatore nel set in corso, ricezione esclusa; filtrati sulle La schermata `/scout` ha due passi: **Selezione giocatore** e **Valuta** (vedi sotto), seguiti da
risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha risposto) e **2. Valuta** «Chiudi set» e «Fine partita».
(vedi sotto), seguiti da «Ultime azioni», «Chiudi set» e «Fine partita». Salvataggio automatico 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. - **Selezione giocatore:** griglia dei convocati (chi ha risposto "presente"/"ritardo", con fallback
a tutta la rosa se nessuno ha risposto), ordinata per cognome o per numero con il selettore a
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 **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 azioni (punto attacco, ace, muro, errore nostro, punto e errore avversario), il tabellone con punti
e set e il blocco avversario / in casa / trasferta. Avversario e campo restano quelli ricavati dal 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 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`), (`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: i totali `totaliPerGiocatore()` e il campo `scout_partite.azioni` restano nel codice e nei dati:
@@ -85,10 +90,10 @@ leggono le partite scoutate prima di questa modifica, ma oggi nessuna schermata
Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro 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 ([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 (primo passo) e si tocca − o + sul fondamentale (passo 2, il cui titolo mostra totali P/A/M/E. Si seleziona il giocatore e si tocca − o + sul fondamentale: il titolo della sezione
cognome e numero del giocatore e il set in corso); mostra cognome e numero del giocatore e il set in corso, e accanto a ogni tasto compare il conteggio
accanto a ogni tasto compare il conteggio di quel giocatore **nel set in corso**. «Annulla» nella di quel giocatore **nel set in corso**. «Annulla» nella sezione toglie l'ultima valutazione del set in
sezione toglie l'ultima valutazione del set in corso (l'«Annulla» in alto agisce solo sulle azioni, quindi oggi è sempre spento). 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 Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una
lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()` lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()`
@@ -104,7 +109,7 @@ Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutaz
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde 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**: «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 `haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
referto CSI; resta visibile il «Report tecnico» con il CSV. 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)
@@ -114,11 +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. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con i log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
pagina partita, sezione "Scout".
- e i − di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla pagina partita, ---
sezione "Report tecnico".
## 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.
--- ---
@@ -139,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.
@@ -149,11 +164,8 @@ calcolati a runtime).
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita» - 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ù. entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
- Le valutazioni dei set chiusi e delle partite finite sono salvate ma non si rivedono nell'app: lo - Lo scout in corso mostra solo il set in corso: le valutazioni dei set chiusi si rivedono solo
scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella nella pagina partita, dopo «Fine partita».
pagina partita.
- Il blocco «Report tecnico» della pagina partita mostra ancora Punti/Ace/Muri/Errori (a zero per le
partite di sole valutazioni) e non le valutazioni, che sono solo nel CSV admin.
- 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:
@@ -167,5 +179,5 @@ calcolati a runtime).
## Evoluzioni possibili ## Evoluzioni possibili
- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin) e, nello scout, in sola lettura quelle dei set chiusi. - 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.5.0", "version": "1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.5.0", "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.5.0", "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
+58
View File
@@ -96,6 +96,24 @@ export function contaValutazioni(
return out; 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 * 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. * terra): quello che compare nel suo riquadro dello scout.
@@ -273,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.
+44 -71
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 { haPunteggio, 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";
@@ -90,7 +91,6 @@ function PartitaDetail() {
: csiMatch : csiMatch
? matchDaPartitaCsi(csiMatch) ? matchDaPartitaCsi(csiMatch)
: undefined; : undefined;
const totaliTeam = scout ? totaliSquadra([scout]) : null;
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;
@@ -206,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">
@@ -220,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">
@@ -238,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>
+6 -42
View File
@@ -341,8 +341,6 @@ function ScoutBoard({
const contiSelezionato = selezionato const contiSelezionato = selezionato
? contaValutazioni(valutazioni, selezionato, setCorrente) ? contaValutazioni(valutazioni, selezionato, setCorrente)
: null; : null;
const ultime = [...azioni].slice(-4).reverse();
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">
@@ -386,7 +384,7 @@ function ScoutBoard({
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid grid-cols-4 gap-2"> <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 piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente); const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
@@ -396,12 +394,14 @@ function ScoutBoard({
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",
@@ -419,7 +419,7 @@ function ScoutBoard({
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "} Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
{setCorrente} {setCorrente}
</p> </p>
<button <button
@@ -469,42 +469,6 @@ function ScoutBoard({
</div> </div>
</section> </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">
Ultime azioni
</p>
{ultime.length === 0 ? (
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p>
) : (
<ul className="mt-2 space-y-1.5">
{ultime.map((a, i) => {
const g = rosa.find((x) => x.id === a.giocatoreId);
return (
<li
key={a.id}
className={cn(
"flex items-center justify-between gap-2 px-1 text-[13px]",
i === 0 && "anim-riga",
)}
>
<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>
)}
</div>
</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">
<button <button
type="button" type="button"
+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. */
+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");
+73
View File
@@ -6,11 +6,14 @@ import {
contaPiuSenzaRicezione, contaPiuSenzaRicezione,
haPunteggio, haPunteggio,
parzialiFinali, parzialiFinali,
sintesiValutazioni,
contaValutazioni, contaValutazioni,
fondamentali, fondamentali,
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
totaliSquadraValutazioni,
totaliTecnici,
type Azione, type Azione,
type Valutazione, type Valutazione,
type ScoutMatch, type ScoutMatch,
@@ -66,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 });
@@ -197,3 +254,19 @@ assert.equal(
}), }),
true, 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");