diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index b5fccd7..d8b3c79 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 06/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,28 +9,37 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in ## Stato generale -- **Versione:** 1.5.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — campionato CSI sulla - stagione 2026/27 (nuovi `project_id` e `team_id`, nessuna Coppa). La 1.4.2: ogni evento può avere un link - Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con - convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo - loro. La 1.4.0: 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 lampo» e «Mai un forfait». La 1.3.0 - portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home - ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). +- **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 + lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le + presenze sui convocati, gli avvisi in Home ridisegnati e i caratteri più grandi. Dettaglio in + [docs/CHANGELOG.md](docs/CHANGELOG.md). - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio Project Ref `hetycilxgkdmccelwerq` non si usano più. -- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il - 26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie - `foto_path`: il codice che non la legge più (1.2.2) va rilasciato. +- **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. - **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 è su `main` dalla 1.3.0. - **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 collega al primo accesso (DD-018). -- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in - [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». +- **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). 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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6ea4c92..c5c4771 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,43 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +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 + Home e Classifica compaiono i nomi uniti («Anna e Bruno»). Resta il minimo di 2 voti per eleggere + un MVP. +- Il voto MVP è segreto ([DD-052](decisions/DD-052.md)): nessuno vede chi ha votato chi, solo i + conteggi e il proprio voto. +- 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ù. + +### Sicurezza + +- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per + una partita, tra presenti (`presente` o `ritardo`) e dopo 2 ore dall'inizio (`m36`, + [DD-051](decisions/DD-051.md)). Gli admin possono ancora correggere un voto. +- Il nome del votato lo scrive il database dalla rosa, per i voti MVP (`m37`) e per i badge social + (`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. diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 78ed8a5..1971f2b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -12,21 +12,21 @@ allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura** resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011). -| Tabella | Chi può scrivere | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | -| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | -| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | -| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | -| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | -| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | -| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) | -| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) | -| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. | -| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) | -| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | -| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | -| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | +| Tabella | Chi può scrivere | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | +| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | +| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin | +| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | +| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | +| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | +| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) | +| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) | +| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. | +| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) | +| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | +| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | +| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`. La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale. @@ -88,18 +88,18 @@ 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 Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato, votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027). -| Tabella | Scopo | Note | -| ------------------- | ---------------------------------- | --------------------------------------------------------------------- | -| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. | -| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. | -| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. | +| Tabella | Scopo | Note | +| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- | +| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). | +| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. | +| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). | ## Turni e notifiche diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 293cdc9..1b2f862 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,6 +34,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [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 diff --git a/docs/decisions/DD-051.md b/docs/decisions/DD-051.md new file mode 100644 index 0000000..795a49a --- /dev/null +++ b/docs/decisions/DD-051.md @@ -0,0 +1,45 @@ +# DD-051 — Voto MVP definitivo e MVP a pari merito + +**Data:** 26 ottobre 2026 +**Stato:** Accettata (migration `m35` e `m36`) + +**Contesto** +Il voto MVP si poteva cambiare senza limiti e senza storico (`mvp.md`), e una parità in testa non +assegnava l'MVP a nessuno finché non arrivavano altri voti. Con voti modificabili la classifica di una +partita poteva ribaltarsi dopo che la squadra l'aveva già vista; e una parità, che a fine serata non si +risolve più perché nessuno vota ancora, lasciava la partita senza MVP per sempre. + +**Decisione** + +- **Il voto è definitivo.** Il giocatore lo registra una volta sola per partita. L'interfaccia chiede una + conferma prima di inviarlo e non offre più «Cambia voto»; la mutazione è un `INSERT` e un secondo voto + viene rifiutato dal vincolo `UNIQUE (match_id, votante_id)`. A database (`m35`) il giocatore ha solo la + policy di `INSERT`: `UPDATE` e `DELETE` restano agli admin (policy di M11), che possono correggere un + voto sbagliato. +- **Una parità in testa dà più MVP.** `mvpPartita()` restituisce tutti i più votati; ognuno conta un + titolo in `mvpVintiPerGiocatore()`, e dove si mostra un solo nome per partita (Home, Classifica) + i nomi sono uniti («Anna e Bruno»). Il quorum di DD-028 (`VOTI_MINIMI_MVP = 2`) resta: sotto due voti + totali nessuno è MVP. + +- **Presenza e orario a database (`m36`).** Il voto MVP richiede, oltre a convocati e non-allenatori, che + l'evento sia una partita, che votante e votato siano `presente` o `ritardo` e che siano passate 2 ore + dall'inizio. Sta in una funzione dedicata, `voto_mvp_consentito()`, perché `evento_permette_voto()` è + condivisa con pagelle e badge social, che non hanno queste regole. Gli admin restano esclusi dai + vincoli. + +**Alternative scartate** + +- Lasciare il voto modificabile entro una scadenza → serve una scadenza che non esiste (`mvp.md`, + «Evoluzioni possibili») e un cambio di idea tardivo resta possibile. +- Spareggio con un criterio automatico (media pagella, presenze) → introduce regole nascoste che i + giocatori non vedono; il pari merito è il risultato più semplice da spiegare. + +**Conseguenze** + +- Un voto dato per errore si corregge solo da un admin. +- I voti già dati restano, ma le partite con una parità in testa assegnano ora i titoli: i conteggi MVP + dei giocatori possono salire (badge «MVP» e «Uomo tie-break» compresi). +- Con tre o più pari merito (per esempio tre voti a tre persone diverse) gli MVP sono tre. + +**Riesame** +Se i pari merito risultano troppo frequenti, alzare il quorum (DD-028) o introdurre uno spareggio. diff --git a/docs/decisions/DD-052.md b/docs/decisions/DD-052.md new file mode 100644 index 0000000..c9f251a --- /dev/null +++ b/docs/decisions/DD-052.md @@ -0,0 +1,44 @@ +# DD-052 — Voto MVP segreto, nome dal database e votazione che si chiude + +**Data:** 26 ottobre 2026 +**Stato:** Accettata (migration `m37` e `m38`) + +**Contesto** +Dopo DD-051 il voto è definitivo e controllato a database, ma restavano tre difetti: chiunque poteva +leggere da `mvp_voti` chi aveva votato chi; `votato_nome` lo scriveva il client e poteva non +corrispondere a `votato_id` (il titolo andava all'id, il nome mostrato era un altro); e la votazione, +una volta aperta, non si chiudeva mai. + +**Decisione** + +- **Voto segreto.** La lettura diretta di `mvp_voti` è del solo votante (il proprio voto) e degli admin. + La squadra legge i conteggi dalla vista `mvp_voti_anonimi`, una riga per voto senza `votante_id` e + ordinata per candidato (la sequenza di arrivo non rivela nulla). La vista gira con i permessi del + proprietario: è ciò che le permette di leggere tutte le righe senza esporre chi ha votato. Non è + leggibile senza account. +- **Nome dal database.** Un trigger riempie `votato_nome` da `giocatori_squadra` a partire da + `votato_id`, ignorando quello inviato (anche se omesso, vuoto o diverso; e anche quando un admin corregge + un voto). Se l'id non è in rosa resta il nome ricevuto. Lo stesso trigger vale per i badge social + (`m38`), che avevano lo stesso difetto; le pagelle non hanno un nome del votato. +- **Chiusura.** Il voto si apre 2 ore dopo l'inizio della partita e si chiude 24 ore dopo l'apertura + (cioè 26 ore dopo l'inizio). A database lo impone `voto_mvp_consentito()`; nell'app `votoMvpChiuso()`. + Gli admin restano fuori dal vincolo. +- **Tutte le partite.** Il voto vale per ogni evento di tipo partita, amichevoli comprese (invariato). + +**Alternative scartate** + +- Filtrare i voti solo nell'app → chi scrive su PostgREST li leggerebbe comunque. +- Togliere la lettura anche agli admin → non potrebbero più correggere un voto sbagliato; restano gli + unici a poter leggere la tabella, e l'app non mostra a nessuno chi ha votato chi. +- Chiudere 24 ore dopo l'inizio della partita (22 ore di voto) → si è scelto 24 ore piene dall'apertura. + +**Conseguenze** + +- Le partite già chiuse da più di 26 ore non si votano più: chi non aveva votato perde la possibilità. + I voti già dati restano e contano. +- I conteggi restano visibili durante la votazione: chi vota può vedere chi è in testa. +- Un admin che legge la tabella dal database vede ancora chi ha votato chi. + +**Riesame** +Se si vuole un voto davvero cieco, nascondere i risultati fino alla chiusura (la chiusura a 24 ore lo +rende possibile). diff --git a/docs/decisions/DD-053.md b/docs/decisions/DD-053.md new file mode 100644 index 0000000..a4c6526 --- /dev/null +++ b/docs/decisions/DD-053.md @@ -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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f78a0f8..be064a7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -87,6 +87,9 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand | [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento | +| [DD-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 | diff --git a/docs/modules/mvp.md b/docs/modules/mvp.md index 60b7284..00e43fd 100644 --- a/docs/modules/mvp.md +++ b/docs/modules/mvp.md @@ -2,6 +2,7 @@ **Stato:** implementato **File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx` +**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge` --- @@ -15,7 +16,10 @@ calcolato a runtime. ## Dati Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per -partita, sovrascrivibile. +partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il +proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`, +`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a +partire da `votato_id`: il nome inviato dal client è ignorato. `match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI @@ -27,44 +31,59 @@ restano nel database ma non vengono più letti da nessuna schermata). - Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()` lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo - quando aprirà. Nessun legame con il risultato caricato. + quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura + (DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il + pulsante «Vota». Nessun legame con il risultato caricato. - Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in `usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco. -- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza - limiti, senza storico. +- `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla + tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X». +- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di + confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il + giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin. - Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo `mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle. -- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità, - nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. -- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali +- `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito** + (DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica. +- `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a incoronare nessuno, nemmeno senza concorrenza. -- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il - risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile +- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati + (anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile nel profilo e in home. --- ## Limiti noti -- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. +- Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse: + chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database. +- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di + database vedono chi ha votato chi; l'app non lo mostra a nessuno. +- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una + possibile evoluzione. - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`). Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto - = tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non - può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo - applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti** - (non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto - della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento - (`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. -- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella - partita. + = tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei + controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e + sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto + vale mezzanotte). Sono le stesse + regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore + e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione + (`m37`); un test unitario li tiene allineati. Gli + admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo + al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta. +- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa, + invece, assegna l'MVP a tutti i pari merito. +- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo. --- ## Evoluzioni possibili -- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita). +- Nascondere i risultati finché la votazione non è chiusa. +- Una schermata per gli admin per correggere un voto (oggi solo dal database). diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index 32c3950..27fe384 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -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. diff --git a/src/components/crapp/VotazioneMvp.tsx b/src/components/crapp/VotazioneMvp.tsx index 08adfb7..4d86e46 100644 --- a/src/components/crapp/VotazioneMvp.tsx +++ b/src/components/crapp/VotazioneMvp.tsx @@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi"; import { conteggioPartita, mioVoto, + mvpPartita, ORE_ATTESA_MVP, + useMieiVotiMvp, useVotaMvp, useVotiMvp, + VOTI_MINIMI_MVP, votoMvpAperto, - type VotoMvp, + votoMvpChiuso, + DURATA_VOTO_MVP_ORE, + type VotoMvpPubblico, } from "@/lib/mvp-voti"; /** - * Pannello di votazione MVP di una partita: un voto a testa, modificabile. + * Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in + * testa gli MVP sono più d'uno. * * I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche * senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili @@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). const io = useGiocatoreInCampo(); const voti = useVotiMvp(); + const miei = useMieiVotiMvp(io?.id); const vota = useVotaMvp(); const { righe: squadra } = useGiocatoriSquadra(); const { risposte } = usePresenzeEvento(evento.id); const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; const rosa = squadra.filter((g) => inRosa(g) && presente(g.id)); const [aperto, setAperto] = useState(false); + const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null); - const tutti: VotoMvp[] = voti.data ?? []; + const tutti: VotoMvpPubblico[] = voti.data ?? []; const conteggio = conteggioPartita(tutti, matchId); - const mio = io ? mioVoto(tutti, matchId, io.id) : null; + const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null; + const chiuso = votoMvpChiuso(evento.data, evento.ora); const totale = conteggio.reduce((s, c) => s + c.voti, 0); - const testa = conteggio[0]; - const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti; - const puoVotare = !!io && presente(io.id); + const mvp = mvpPartita(tutti, matchId); + const puoVotare = !!io && !mio && !chiuso && presente(io.id); if (!votoMvpAperto(evento.data, evento.ora)) { return ( @@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { votato_nome: nome, }); setAperto(false); + setScelto(null); toast.success(`Voto MVP registrato: ${nome}`); } catch { - toast.error("Voto non riuscito, riprova"); + toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare"); } } @@ -78,58 +87,87 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {

Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}

- + {mio || chiuso ? null : ( + + )}

- {testa && !pareggio ? ( - - {testa.nome} ({testa.voti}) + {mvp.length > 0 ? ( + + {mvp.map((c) => ( + + {c.nome} ({c.voti}) + + ))} ) : ( - {totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"} + {totale === 0 + ? "Nessun voto: MVP da eleggere" + : `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`} )}

{mio ? ( -

Hai votato {mio.votato_nome}

+

+ Hai votato {mio.votato_nome}: il voto non si può cambiare. +

+ ) : chiuso ? ( +

Votazione chiusa.

) : !puoVotare ? (

Vota chi era presente a questa partita.

- ) : null} + ) : ( +

+ Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore. +

+ )} - {aperto ? ( -
- {/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato + {aperto && !mio ? ( + <> +
+ {/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato anche dal vincolo `mvp_no_autovoto` (M12). */} - {rosa - .filter((g) => g.id !== io?.id) - .map((g) => ( - - ))} -
+ {rosa + .filter((g) => g.id !== io?.id) + .map((g) => ( + + ))} +
+ +

+ Il voto è definitivo: non si può cambiare. +

+ ) : conteggio.length > 0 ? (
{conteggio.map((c) => ( diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 4fec40f..f272d6f 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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: []; }; @@ -625,7 +628,14 @@ export type Database = { }; }; Views: { - [_ in never]: never; + mvp_voti_anonimi: { + Row: { + match_id: string | null; + votato_id: string | null; + votato_nome: string | null; + }; + Relationships: []; + }; }; Functions: { has_role: { diff --git a/src/lib/mvp-voti.ts b/src/lib/mvp-voti.ts index d7ebdcc..546b827 100644 --- a/src/lib/mvp-voti.ts +++ b/src/lib/mvp-voti.ts @@ -8,52 +8,91 @@ export type VotoMvp = { votato_nome: string; }; +/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */ +export type VotoMvpPubblico = Pick; + const CHIAVE = ["mvp-voti"] as const; +const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const; /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */ export const ORE_ATTESA_MVP = 2; -/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */ +/** Per quante ore resta aperto il voto, dall'apertura (DD-052). */ +export const DURATA_VOTO_MVP_ORE = 24; + +/** Il voto MVP apre 2 ore dopo l'inizio della partita. */ export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean { const inizio = new Date(`${data}T${ora || "00:00"}:00`); if (Number.isNaN(inizio.getTime())) return false; return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000; } -/** Tutti i voti MVP della squadra (poche righe, si carica tutto). +/** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */ +export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean { + const inizio = new Date(`${data}T${ora || "00:00"}:00`); + if (Number.isNaN(inizio.getTime())) return false; + const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE; + return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000; +} + +/** Si può votare adesso: aperto e non ancora chiuso. */ +export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean { + return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso); +} + +/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto). + * Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052). * Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */ export function useVotiMvp() { return useQuery({ queryKey: CHIAVE, staleTime: 10 * 60_000, + queryFn: async (): Promise => { + const { data, error } = await supabase + .from("mvp_voti_anonimi") + .select("match_id, votato_id, votato_nome"); + if (error) throw error; + return (data ?? []) as VotoMvpPubblico[]; + }, + }); +} + +/** I voti che ho dato io: sono gli unici che la tabella mi lascia leggere. */ +export function useMieiVotiMvp(votanteId: string | undefined) { + return useQuery({ + queryKey: chiaveMiei(votanteId), + enabled: !!votanteId, + staleTime: 10 * 60_000, queryFn: async (): Promise => { const { data, error } = await supabase .from("mvp_voti") - .select("match_id, votante_id, votato_id, votato_nome"); + .select("match_id, votante_id, votato_id, votato_nome") + .eq("votante_id", votanteId!); if (error) throw error; return (data ?? []) as VotoMvp[]; }, }); } +/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */ export function useVotaMvp() { const qc = useQueryClient(); return useMutation({ mutationFn: async (voto: VotoMvp) => { - const { error } = await supabase - .from("mvp_voti") - .upsert(voto, { onConflict: "match_id,votante_id" }); + const { error } = await supabase.from("mvp_voti").insert(voto); if (error) throw error; return voto; }, // Aggiorna la cache localmente: nessuna rilettura dal database. onSuccess: (voto) => { - qc.setQueryData(CHIAVE, (prec) => { - const altri = (prec ?? []).filter( - (v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id), - ); - return [...altri, voto]; - }); + qc.setQueryData(chiaveMiei(voto.votante_id), (prec) => [ + ...(prec ?? []).filter((v) => v.match_id !== voto.match_id), + voto, + ]); + qc.setQueryData(CHIAVE, (prec) => [ + ...(prec ?? []), + { match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome }, + ]); }, }); } @@ -64,7 +103,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number }; export const VOTI_MINIMI_MVP = 2; /** Conteggio voti di una partita, dal più votato. */ -export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { +export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] { const map = new Map(); for (const v of voti) { if (v.match_id !== matchId) continue; @@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome)); } -/** Vincitore per ogni partita votata: matchId -> nome MVP. */ -export function vincitoriMvp(voti: VotoMvp[]): Record { - const perMatch = new Map(); - for (const v of voti) { - const arr = perMatch.get(v.match_id) ?? []; - arr.push(v); - perMatch.set(v.match_id, arr); - } +/** + * MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari + * merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora. + */ +export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] { + const top = conteggioPartita(voti, matchId); + const totaleVoti = top.reduce((s, c) => s + c.voti, 0); + if (totaleVoti < VOTI_MINIMI_MVP) return []; + return top.filter((c) => c.voti === top[0]!.voti); +} + +/** «Anna», «Anna e Bruno», «Anna, Bruno e Carla». */ +export function elencoNomi(nomi: string[]): string { + if (nomi.length <= 1) return nomi.join(""); + return `${nomi.slice(0, -1).join(", ")} e ${nomi[nomi.length - 1]}`; +} + +/** MVP per ogni partita votata: matchId -> nome (più nomi uniti se c'è una parità in testa). */ +export function vincitoriMvp(voti: VotoMvpPubblico[]): Record { const out: Record = {}; - for (const [matchId] of perMatch) { - const top = conteggioPartita(voti, matchId); - const totaleVoti = top.reduce((s, c) => s + c.voti, 0); - // In caso di parità, o sotto il quorum minimo, nessun MVP assegnato. - if ( - totaleVoti >= VOTI_MINIMI_MVP && - top.length > 0 && - (top.length === 1 || top[0]!.voti > top[1]!.voti) - ) { - out[matchId] = top[0]!.nome; - } + for (const matchId of new Set(voti.map((v) => v.match_id))) { + const mvp = mvpPartita(voti, matchId); + if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome)); } return out; } @@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) { } /** - * MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio - * al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti + * MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di + * parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti * restituisce una mappa vuota. */ -export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record { +export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record { const out: Record = {}; - const matchIds = new Set(voti.map((v) => v.match_id)); - for (const matchId of matchIds) { - const top = conteggioPartita(voti, matchId); - const totaleVoti = top.reduce((s, c) => s + c.voti, 0); - if ( - totaleVoti >= VOTI_MINIMI_MVP && - top.length > 0 && - (top.length === 1 || top[0]!.voti > top[1]!.voti) - ) { - const id = top[0]!.id; - out[id] = (out[id] ?? 0) + 1; - } + for (const matchId of new Set(voti.map((v) => v.match_id))) { + for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1; } return out; } diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 62d4f5d..9fbb25b 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string { export function useAnagraficaRosa({ conAllenatori = false, }: { conAllenatori?: boolean } = {}): Array< - Pick + Pick & { 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 ?? "", diff --git a/src/lib/scout-export.ts b/src/lib/scout-export.ts index bafe5cc..d0d7dc5 100644 --- a/src/lib/scout-export.ts +++ b/src/lib/scout-export.ts @@ -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) { @@ -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) { diff --git a/src/lib/scout-stato.ts b/src/lib/scout-stato.ts index 10d568e..785ec61 100644 --- a/src/lib/scout-stato.ts +++ b/src/lib/scout-stato.ts @@ -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, diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index b89638f..3eb5114 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -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; + +/** 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 { 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): 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(); diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index a06abed..5e658e2 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -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, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1838ff0..66b05ed 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 64dcdf0..7b84561 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -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) diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 36f9711..ae6eb9e 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -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>(base.setChiusi); const [azioni, setAzioni] = useState(base.azioni); + const [valutazioni, setValutazioni] = useState(base.valutazioni ?? []); + const [ordine, setOrdine] = useState<"cognome" | "numero">("cognome"); const [selezionato, setSelezionato] = useState(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.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({
- -
-
-

CRAP Volley

-

{puntiNoi}

-
-

- {setNostri}-{setLoro} -

-
-

- {avversario || "Avversario"} -

-

{puntiLoro}

-
-
- - {setChiusi.length > 0 ? ( -
- {setChiusi.map((p, i) => ( - - {p[0]}-{p[1]} - - ))} -
- ) : null}
-

- 1. Tocca il giocatore -

+
+

+ Selezione giocatore +

+
+ +
+
{convocati.map((g) => { const attivo = selezionato === g.id; - const t = totali.get(g.id); + const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente); return ( ); @@ -400,39 +417,55 @@ function ScoutBoard({
-

- 2. Tocca l'azione -

-
- {ordineAzioni.map((tipo) => ( - - ))} +
+

+ 2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "} + {setCorrente} +

+
-
- {(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => ( - - ))} +
+ {!selezionato ? ( +

+ Tocca un giocatore: i + e i − non cambiano il punteggio. +

+ ) : null} + {fondamentali.map((f) => { + const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 }; + return ( +
+ {f.label} + + +
+ ); + })}
@@ -472,35 +505,6 @@ function ScoutBoard({ -
- - - setAvversario(e.target.value)} - className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]" - /> -
- {[true, false].map((v) => ( - - ))} -
-
-
-