10 Commits
Author SHA1 Message Date
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
29 changed files with 716 additions and 268 deletions

No files matched your search

+12 -10
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 26/10/2026
Ultimo aggiornamento: 02/11/2026
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
@@ -9,9 +9,10 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale
- **Versione:** 1.4.2 in produzione su `main` (tag `v1.4.2`): ogni evento può avere un link Google
Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro
non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si
- **Versione:** 1.5.0 su `main` (tag `v1.5.0`): campionato CSI sulla stagione 2026/27 (nuovi `project_id`
e `team_id`, nessuna Coppa). Su `feature/gara` c'è del lavoro non ancora rilasciato (voto MVP, vedi
«Lavoro in corso»). 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:
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
@@ -20,11 +21,11 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
[docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione
sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con
`npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`**
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
- **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione
sono applicate 45 su 50, fino alla `m34_link_maps_evento` (verificato con
`npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m39`, nessuna
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m39`**
(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.
@@ -36,7 +37,8 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
collega al primo accesso (DD-018).
- **Lavoro in corso:** branch `feature/gara` per le funzioni di gara (correzioni e nuove
funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione
tra presenti, con i pari merito (DD-051, DD-052). Poi lo scout live ridotto alle valutazioni − / +
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
+31 -1
View File
@@ -13,10 +13,24 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**.
### 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 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.
- 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).
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
@@ -36,6 +50,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
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
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_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
+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
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
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] 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
calcolata dal CSI
- [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
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi
- [ ] AI Allenamenti
- [ ] Archivio allenamenti
+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-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-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
+38 -38
View File
@@ -1,6 +1,6 @@
# 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`
(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
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
### Identificativi (stagione 2026/27)
| Cosa | Valore |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| `project_id` (girone) | `933` |
| Coppa | nessuna: la squadra non è iscritta |
| `project_id` (coppa) | `null` |
| Squadra sul portale | `C.R.A.P. Volley` |
| `team_id` | `3878` |
| 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`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
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)).
**A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
`3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
`3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
classifica è tutta a zero (giocate 0): non è un errore di parsing.
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
`team_id` si legge dal link della squadra nella pagina del girone) oppure
`components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
`933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
`CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `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=<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=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>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
(`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
@@ -247,9 +245,10 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
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`.
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
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
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
`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
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
@@ -258,20 +257,21 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
`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`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
**La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
`/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
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
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
`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
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
+52 -11
View File
@@ -10,8 +10,8 @@
## Obiettivo
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
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
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
aggiornato da qualunque dispositivo.
---
@@ -21,7 +21,8 @@ aggiornato da qualunque dispositivo.
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
`evento_id`, quindi un solo detentore).
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni,
valutazioni − / +),
mai più modificato una volta salvato (solo eliminabile per intero).
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
(`UNIQUE evento_id, giocatore_id`).
@@ -65,17 +66,46 @@ perché occupava spazio 6 giorni su 7.
## Cosa registra
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
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
risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha risposto) e **2. Valuta**
(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.
**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 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 (primo passo) e si tocca − o + sul fondamentale (passo 2, il cui titolo mostra
cognome e numero del giocatore e il set in corso);
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
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 «Report tecnico» con il CSV.
`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)
e rilascia la sessione.
@@ -85,8 +115,10 @@ e rilascia la sessione.
## Export CSV
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
sezione "Report tecnico".
giocatore e log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con i
- e i − di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla pagina partita,
sezione "Report tecnico".
---
@@ -115,10 +147,18 @@ calcolati a runtime).
## 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ù.
- Le valutazioni dei set chiusi e delle partite finite sono salvate ma non si rivedono nell'app: lo
scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella
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
garanzia è il lock di sessione (vedi sopra).
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
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.
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
partite cadono lo stesso giorno.
@@ -127,4 +167,5 @@ calcolati a runtime).
## 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.
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"sideEffects": false,
"type": "module",
+3
View File
@@ -536,6 +536,7 @@ export type Database = {
parziali: Json;
set_loro: number;
set_nostri: number;
valutazioni: Json;
};
Insert: {
avversario: string;
@@ -548,6 +549,7 @@ export type Database = {
parziali?: Json;
set_loro: number;
set_nostri: number;
valutazioni?: Json;
};
Update: {
avversario?: string;
@@ -560,6 +562,7 @@ export type Database = {
parziali?: Json;
set_loro?: number;
set_nostri?: number;
valutazioni?: Json;
};
Relationships: [];
};
+10 -9
View File
@@ -8,16 +8,17 @@ import type { RigaClassifica } from "./crapp-data";
*/
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/** Campionato Open Misto Eccellenza 2026/27. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 933;
/**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
* Coppa CSI della stagione in corso, `null` se la squadra non è iscritta (nel 2026/27 non c'è).
* Stesso formato di tabella del campionato (girone all'italiana), solo con meno squadre:
* `parseClassifica()` funziona invariata. Cambia a ogni stagione come CSI_PROJECT_ID, vedi
* docs/modules/collegamento-csi.md.
*/
export const CSI_COPPA_PROJECT_ID = 848;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_COPPA_PROJECT_ID: number | null = null;
/** C.R.A.P. Volley sul portale CSI: l'id cambia a ogni stagione (2025/26 era 3359). */
export const CSI_TEAM_ID = 3878;
export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
@@ -133,7 +134,7 @@ export type DettaglioPartitaCsi = {
export type DatiCsi = {
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[];
partite: PartitaCsi[];
girone: string;
+2 -1
View File
@@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string {
export function useAnagraficaRosa({
conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
> {
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(
@@ -44,6 +44,7 @@ export function useAnagraficaRosa({
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
cognome: g.cognome,
ruolo: g.ruolo,
numero: g.numero,
nascita: g.nascita ?? "",
+33 -1
View File
@@ -1,5 +1,11 @@
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. */
export function rigaCsv(campi: Array<string | number>) {
@@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
if (!t) continue;
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(rigaCsv(["Set", "Giocatore", "Azione"]));
for (const a of match.azioni) {
+4 -1
View File
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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. */
export type StatoScout = {
azioni: Azione[];
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
valutazioni?: Valutazione[];
setChiusi: Array<[number, number]>;
avversario: string;
casa: boolean;
@@ -12,6 +14,7 @@ export type StatoScout = {
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
azioni: [],
valutazioni: [],
setChiusi: [],
avversario,
casa,
+80 -1
View File
@@ -60,6 +60,72 @@ export type Azione = {
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;
}
/**
* 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 = {
id: string;
data: string;
@@ -69,6 +135,7 @@ export type ScoutMatch = {
setLoro: number;
parziali: Array<[number, number]>;
azioni: Azione[];
valutazioni: Valutazione[];
};
type RigaScoutPartita = {
@@ -80,6 +147,7 @@ type RigaScoutPartita = {
set_loro: number;
parziali: unknown;
azioni: unknown;
valutazioni: unknown;
};
function daRiga(r: RigaScoutPartita): ScoutMatch {
@@ -92,6 +160,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
setLoro: r.set_loro,
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
azioni: (r.azioni as Azione[] | null) ?? [],
valutazioni: (r.valutazioni as Valutazione[] | null) ?? [],
};
}
@@ -100,7 +169,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
const { data, error } = await supabase
.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 });
if (error) throw error;
return (data ?? []).map(daRiga);
@@ -131,6 +200,7 @@ export function useSalvaScoutMatch() {
set_loro: input.match.setLoro,
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
});
if (error) throw error;
return input.match;
@@ -159,6 +229,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. */
export function totaliPerGiocatore(azioni: Azione[]) {
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
+3 -1
View File
@@ -33,7 +33,9 @@ async function leggiCsi(): Promise<DatiCsi> {
scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()),
]);
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 { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
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 {
isNostraSquadra,
@@ -150,7 +150,7 @@ function Classifica() {
const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false };
})
: scoutMatches.map((m) => ({
: scoutMatches.filter(haPunteggio).map((m) => ({
id: m.id,
data: m.data,
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 { useCsi } from "@/lib/csi";
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 { mediaVotoColpoDOcchio } from "@/lib/pagelle";
@@ -63,7 +63,9 @@ function Index() {
const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
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 obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
const eventoUltima = ultimaBase
+21 -20
View File
@@ -11,7 +11,7 @@ import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/cr
import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store";
@@ -70,25 +70,26 @@ function PartitaDetail() {
const csiMatch = csi
? partiteGiocate(csi.partite).find((p) => p.data === evento.data)
: undefined;
const match = scout
? {
id: scout.id,
data: scout.data,
avversario: scout.avversario,
logoAvversario: "",
casa: scout.casa,
setNostri: scout.setNostri,
setLoro: scout.setLoro,
parziali: scout.parziali,
campo: "",
girone: "",
numeroGara: "",
arbitro: "",
link: "",
}
: csiMatch
? matchDaPartitaCsi(csiMatch)
: undefined;
const match =
scout && haPunteggio(scout)
? {
id: scout.id,
data: scout.data,
avversario: scout.avversario,
logoAvversario: "",
casa: scout.casa,
setNostri: scout.setNostri,
setLoro: scout.setLoro,
parziali: scout.parziali,
campo: "",
girone: "",
numeroGara: "",
arbitro: "",
link: "",
}
: csiMatch
? matchDaPartitaCsi(csiMatch)
: undefined;
const totaliTeam = scout ? totaliSquadra([scout]) : null;
const avversario = evento.titolo.includes(" vs ")
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
+134 -130
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 { toast } from "sonner";
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 type { Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa";
@@ -27,10 +27,15 @@ import {
} from "@/lib/scout-live";
import {
azioniMeta,
contaPiuSenzaRicezione,
contaValutazioni,
parzialiFinali,
fondamentali,
useSalvaScoutMatch,
totaliPerGiocatore,
type Azione,
type AzioneTipo,
type Fondamentale,
type Valutazione,
} from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({
@@ -52,8 +57,6 @@ export const Route = createFileRoute("/scout")({
component: Scout,
});
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({
icona,
titolo,
@@ -119,7 +122,11 @@ function Scout() {
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
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) {
return (
@@ -196,10 +203,12 @@ function ScoutBoard({
.trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"),
);
const [avversario, setAvversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa);
const [avversario] = useState(base.avversario);
const [casa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
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 salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout();
@@ -208,52 +217,73 @@ function ScoutBoard({
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
const rosa = useAnagraficaRosa();
const finito = useRef(false);
const codaSalvataggio = useRef<Promise<unknown>>(Promise.resolve());
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
const convocati = useMemo(() => {
const presenti = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
);
return presenti.length > 0 ? presenti : rosa;
}, [risposte, rosa]);
const elenco = presenti.length > 0 ? presenti : 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.
useEffect(() => {
if (finito.current) return;
const id = window.setTimeout(() => {
void salva.mutateAsync({
eventoId: partita.id,
stato: { azioni, setChiusi, avversario, casa },
// Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si
// chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito
// rimette a posto tutto; la coda li tiene in ordine.
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
}, [azioni, setChiusi, avversario, casa, partita.id]);
}, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]);
const setCorrente = setChiusi.length + 1;
const azioniSet = azioni.filter((a) => a.set === setCorrente);
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
const puntiLoro = azioniSet.length - puntiNoi;
const setNostri = setChiusi.filter(([n, l]) => n > l).length;
const setLoro = setChiusi.length - setNostri;
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
function registra(tipo: AzioneTipo) {
const meta = azioniMeta[tipo];
if (meta.richiedeGiocatore && !selezionato) {
function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
if (!selezionato) {
toast.error("Seleziona prima il giocatore");
return;
}
setAzioni((prev) => {
const nuova: Azione = {
setValutazioni((prev) => [
...prev,
{
id: `${Date.now()}-${prev.length}`,
tipo,
giocatoreId: selezionato,
fondamentale,
segno,
set: setCorrente,
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() {
@@ -266,17 +296,16 @@ function ScoutBoard({
}
function chiudiSet() {
if (puntiNoi === 0 && puntiLoro === 0) {
toast.error("Set senza azioni");
if (!setHaDati) {
toast.error("Set senza valutazioni");
return;
}
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
toast.success(`Set ${setCorrente} chiuso`);
}
async function finePartita() {
const parziali: Array<[number, number]> =
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
if (parziali.length === 0) {
toast.error("Nessun set da salvare");
return;
@@ -294,19 +323,24 @@ function ScoutBoard({
setLoro: parziali.length - vinti,
parziali,
azioni,
valutazioni,
},
});
} catch {
toast.error("Salvataggio non riuscito, riprova");
return;
}
toast.success("Partita salvata: ora la squadra può votare l'MVP");
toast.success("Partita salvata");
finito.current = true;
void cancella.mutateAsync(partita.id);
onFine();
navigate({ to: "/squadra" });
}
const giocatoreSel = rosa.find((g) => g.id === selezionato);
const contiSelezionato = selezionato
? contaValutazioni(valutazioni, selezionato, setCorrente)
: null;
const ultime = [...azioni].slice(-4).reverse();
return (
@@ -334,45 +368,28 @@ function ScoutBoard({
</button>
</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>
<section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
1. Tocca il giocatore
</p>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Selezione giocatore
</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-4 gap-2">
{convocati.map((g) => {
const attivo = selezionato === g.id;
const t = totali.get(g.id);
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
return (
<button
key={g.id}
@@ -391,7 +408,7 @@ function ScoutBoard({
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)}
>
{t ? `${t.punti}p` : "0p"}
+{piu}
</p>
</button>
);
@@ -400,39 +417,55 @@ function ScoutBoard({
</section>
<section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
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 className="mb-2 flex items-center justify-between gap-2">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
{setCorrente}
</p>
<button
type="button"
onClick={annullaValutazione}
disabled={!valutazioni.some((v) => v.set === setCorrente)}
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"
>
<Undo2 className="h-3.5 w-3.5" /> Annulla
</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
className={cn(
"space-y-2 rounded-3xl bg-card p-3 shadow-card",
!selezionato && "opacity-60",
)}
>
{!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>
</section>
@@ -472,35 +505,6 @@ function ScoutBoard({
</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 className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
<button
type="button"
@@ -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 noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok(
csi.partite.some((p) => p.setNostri !== null),
"ci sono risultati giocati",
);
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata.
assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale");
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
+8 -5
View File
@@ -124,9 +124,13 @@ try {
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
});
await provaCsi(
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
async () => {
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test
// del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile.
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];
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
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}`));
assert.equal(secondo.status, 200);
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
},
);
});
await provaCsi(
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
+27
View File
@@ -32,6 +32,7 @@ if (!locale) {
"turni_palloni",
"risposte_presenze",
"scout_live",
"scout_partite",
] as const;
const rest = (percorso: string, init?: RequestInit) =>
@@ -312,6 +313,32 @@ if (!locale) {
"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 {
for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
+34 -23
View File
@@ -333,42 +333,51 @@ assert.equal(
if (process.env["CSI_LIVE"]) {
const [html, htmlCoppa, json] = await Promise.all([
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()),
]);
const live = parseClassifica(html);
const nostra = live.find((r) => isNostraSquadra(r.squadra));
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);
assert.ok(
liveCoppa.some((r) => isNostraSquadra(r.squadra)),
"la nostra squadra è nel girone di Coppa",
);
if (CSI_COPPA_PROJECT_ID !== null) {
assert.ok(
liveCoppa.some((r) => isNostraSquadra(r.squadra)),
"la nostra squadra è nel girone di Coppa",
);
}
const livePartite = partiteDaEventi(json);
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(
liveGiocate.every((p) => p.link.startsWith("https://")),
"ogni partita ha un referto",
);
const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
]);
const liveFormazioni = parseFormazioni(livePlayers);
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
const livePrecedenti = parsePrecedenti(liveStats);
assert.ok(livePrecedenti, "precedenti riconosciuti");
console.log(
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
);
// A inizio stagione nessuna gara è ancora giocata: niente referto da leggere.
if (liveGiocate.length === 0) {
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, nessuna giocata`);
} else {
const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
]);
const liveFormazioni = parseFormazioni(livePlayers);
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
const livePrecedenti = parsePrecedenti(liveStats);
assert.ok(livePrecedenti, "precedenti riconosciuti");
console.log(
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
);
}
}
// --- 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-08-01"), "2026/27");
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([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
+40
View File
@@ -29,6 +29,7 @@ const match: ScoutMatch = {
[25, 18],
],
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
valutazioni: [],
};
const csv = csvScoutMatch(match, giocatori);
@@ -78,3 +79,42 @@ const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }, giocat
assert.ok(conVirgolette.includes('"Team ""X"""'));
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 -----------
const stato = statoIniziale("Avversari", true);
// 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);
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.
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
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 --------------------------
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
+113
View File
@@ -3,10 +3,16 @@ import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import {
azioniMeta,
contaPiuSenzaRicezione,
haPunteggio,
parzialiFinali,
contaValutazioni,
fondamentali,
giocatoriConScout,
totaliPerGiocatore,
totaliSquadra,
type Azione,
type Valutazione,
type ScoutMatch,
} from "@/lib/scout-store";
@@ -32,6 +38,7 @@ const match = (
setLoro,
parziali: [],
azioni,
valutazioni: [],
});
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
@@ -84,3 +91,109 @@ for (const [tipo, meta] of Object.entries(azioniMeta)) {
}
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,
);