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

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

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

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

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

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

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

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

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

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

# Conflicts:
#	PROJECT_STATE.md
#	docs/CHANGELOG.md
2026-10-29 08:34:28 +01:00
davideandClaude Sonnet 5.5 9d047a6ecd Documentazione: DD-051 e DD-052, modulo MVP, database e changelog
Voto MVP definitivo, a pari merito, segreto e a tempo; nome del votato dalla rosa. PROJECT_STATE
segna che m35-m38 restano da applicare su Supabase (db push rinviato) e il lavoro su feature/gara.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:59:40 +01:00
davideandClaude Sonnet 5.5 5fed105850 Test del voto MVP: regole a database, segretezza, nome dalla rosa, chiusura
Integrazione (permessi, permessi-allenatore, mvp-badge, s-tiebreak-badge), unit (mvp-voti) e helper
delle date di Roma. Con le parità l'MVP vale per tutti i pari merito.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 127d5cb5b8 Voto MVP: definitivo, a pari merito, segreto e a tempo nell'app
Il pannello chiede conferma e non permette di cambiare il voto; con una parità in testa gli MVP
sono più d'uno; i conteggi vengono dalla vista mvp_voti_anonimi e il proprio voto dalla tabella;
il voto si chiude 24 ore dopo l'apertura.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 e5fac24916 Migration m38: nome del votato dalla rosa anche per i badge social
Stesso difetto dell'MVP: votato_nome lo scriveva il client. Riusa il trigger della m37.
Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 b17605ae76 Migration m37: voto MVP segreto, nome dalla rosa, chiusura a 24 ore
Lettura diretta di mvp_voti solo del votante e degli admin, conteggi dalla vista
mvp_voti_anonimi; trigger che riempie votato_nome dalla rosa; il voto chiude 24 ore dopo
l'apertura. Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 92e7195357 Migration m36: il voto MVP solo tra presenti e dopo 2 ore
voto_mvp_consentito() controlla partita, presenza (presente o ritardo) di votante e votato e
l'attesa di 2 ore dall'inizio. Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
davideandClaude Sonnet 5.5 215090d297 Migration m35: il voto MVP è definitivo
Il giocatore può solo inserire il proprio voto: UPDATE e DELETE restano agli admin.
Da applicare su Supabase con db push (non ancora fatto).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 09:57:35 +01:00
42 changed files with 1590 additions and 346 deletions

No files matched your search

+18 -15
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 06/10/2026 Ultimo aggiornamento: 02/11/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo [docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
@@ -9,28 +9,31 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.5.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — campionato CSI sulla - **Versione:** 1.6.0 su `main` (tag `v1.6.0`), pubblicata il 02/11/2026: voto MVP definitivo, segreto e a
stagione 2026/27 (nuovi `project_id` e `team_id`, nessuna Coppa). La 1.4.2: ogni evento può avere un link tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
loro. La 1.4.0: avviso «Presenza modificata» ad admin e vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0 mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). 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 - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. 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 - **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
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 applicate tutte e 50 (`npx supabase db push` del 02/11/2026: `m35`-`m39`, verificato con
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. `npx supabase migration list --linked`; nessuna migration remota è assente dal repository). Prima
del push è stato salvato lo schema di produzione (solo schema, non i dati).
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0. è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano - **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018). collega al primo accesso (DD-018).
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in - **Lavoro in corso:** nessuno su branch. Le voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». «Prossimo». Da decidere sullo scout: il pulsante «Annulla» in alto agisce solo sulle azioni, che non
si creano più, quindi è sempre spento.
## Cose da sapere ## Cose da sapere
+45
View File
@@ -13,6 +13,51 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
## [1.6.0] - 2026-11-02
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
applicate in produzione il 02/11/2026.
### Aggiunto
- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro,
separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il
CSV admin della partita include una tabella con i conteggi per set.
### Modificato
- Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set
([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le
partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita.
- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
se non riesce, compare un avviso.
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di
ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti.
- Scout live: dopo «Fine partita» il messaggio dice solo «Partita salvata» (il voto MVP apre 2 ore
dopo l'inizio della partita, non dipende dallo scout).
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
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ù.
- I test non pretendono più una gara CSI già giocata: a inizio stagione il calendario c'è ma i
risultati no (un test si salta finché non c'è una gara giocata).
### Sicurezza
- 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 ## [1.5.0] - 2026-10-29
Campionato CSI 2026/27. Nessuna migration. Campionato CSI 2026/27. Nessuna migration.
+6 -6
View File
@@ -13,10 +13,10 @@ resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le ec
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011). sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere | | Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | | `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 | | `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 | | `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 | | `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_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) | | `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) |
@@ -88,7 +88,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. | | `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. | | `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. | | `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. |
## Votazioni ## Votazioni
@@ -96,10 +96,10 @@ Per tutte: un voto per votante e partita (per categoria nei badge social), auto-
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027). votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- | | ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. | | `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`. | | `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`. | | `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
## Turni e notifiche ## Turni e notifiche
+1 -1
View File
@@ -18,7 +18,7 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). | | **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. | | **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). | | **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
| **Scout / Scout Live** | Rilevazione in tempo reale delle azioni di una partita, con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). | | **Scout / Scout Live** | Rilevazione in tempo reale, durante la partita, delle valutazioni − / + di ogni convocato su battuta, ricezione, attacco e muro, per set (DD-053), con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). | | **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). | | **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). | | **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
+2 -2
View File
@@ -34,6 +34,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Collegamento CSI (stagione 2026/27) - [x] Collegamento CSI (stagione 2026/27)
- [x] Classifica automatica (campionato e Coppa) - [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato - [x] Risultati campionato
- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato:
a inizio stagione il calendario c'è prima dei risultati
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria - [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
calcolata dal CSI calcolata dal CSI
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza - [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
@@ -54,8 +56,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
## Prossimo ## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
Campionato usa solo quelle giocate
- [ ] Database esercizi - [ ] Database esercizi
- [ ] AI Allenamenti - [ ] AI Allenamenti
- [ ] Archivio allenamenti - [ ] Archivio allenamenti
+45
View File
@@ -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.
+44
View File
@@ -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).
+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.
+3
View File
@@ -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-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [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-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-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-047](DD-047.md) | Notifica di compleanno |
+3 -2
View File
@@ -230,7 +230,8 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
tabella/lista, sia per classifica/partite sia per formazioni/precedenti. tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo - **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`). ricadono sulle partite dello Scout Live che hanno un punteggio (`useScoutMatches()` filtrato con
`haPunteggio()`: quelle di sole valutazioni non sono un risultato).
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai `/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina. mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
@@ -243,7 +244,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il 1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`. sulle partite dello Scout Live con punteggio, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing. Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi 2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
+38 -19
View File
@@ -2,6 +2,7 @@
**Stato:** implementato **Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx` **File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
**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 ## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per 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 `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 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()` - 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 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 - 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. `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 - `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
limiti, senza storico. 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 - 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 `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. direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità, - `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. (DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali - `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 sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza. incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il - `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile (anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home. nel profilo e in home.
--- ---
## Limiti noti ## 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 - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`). 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 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 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 = tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti** sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto vale mezzanotte). Sono le stesse
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella (`m37`); un test unitario li tiene allineati. Gli
partita. 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 ## 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).
+63 -17
View File
@@ -1,6 +1,6 @@
# Modulo — Scout Live # Modulo — Scout Live
**Stato:** implementato (fix M7 per la persistenza condivisa) **Stato:** implementato (fix M7 per la persistenza condivisa; valutazioni − / + dalla 1.6.0, DD-053)
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`, **File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`, `src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx` `src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
@@ -10,9 +10,9 @@
## Obiettivo ## Obiettivo
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita, Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato,
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato per set, con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che chi
aggiornato da qualunque dispositivo. riprende lo scout trovi lo stato aggiornato, da qualunque dispositivo.
--- ---
@@ -20,9 +20,9 @@ aggiornato da qualunque dispositivo.
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per - `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
`evento_id`, quindi un solo detentore). `evento_id`, quindi un solo detentore).
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione. - `scout_live` — stato in corso (valutazioni, set chiusi, avversario e campo) di una sessione.
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni), - `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni,
mai più modificato una volta salvato (solo eliminabile per intero). valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero).
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento - `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
(`UNIQUE evento_id, giocatore_id`). (`UNIQUE evento_id, giocatore_id`).
@@ -65,17 +65,52 @@ perché occupava spazio 6 giorni su 7.
## Cosa registra ## Cosa registra
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`, La schermata `/scout` ha due passi: **Selezione giocatore** e **Valuta** (vedi sotto), seguiti da
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro, «Chiudi set» e «Fine partita».
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati - **Selezione giocatore:** griglia dei convocati (chi ha risposto "presente"/"ritardo", con fallback
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha a tutta la rosa se nessuno ha risposto), ordinata per cognome o per numero con il selettore a
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento. destra del titolo (default cognome). In ogni riquadro, sotto il nome, compaiono i **+** del
giocatore nel set in corso, ricezione esclusa (`contaPiuSenzaRicezione()`).
- **Salvataggio** su `scout_live` a ogni cambiamento, senza attesa: i salvataggi vanno in coda, uno
alla volta e in ordine, e ognuno scrive lo stato intero (uno riuscito rimedia a uno fallito). Se
un salvataggio non riesce compare un avviso fisso, che sparisce al salvataggio successivo riuscito.
**Punteggio e azioni.** L'interfaccia non registra più il punteggio: sono stati tolti i tasti delle
azioni (punto attacco, ace, muro, errore nostro, punto e errore avversario), il tabellone con punti
e set, l'elenco «Ultime azioni» e il blocco avversario / in casa / trasferta. Avversario e campo restano quelli ricavati dal
titolo della partita (o dallo stato già salvato) e non si modificano. I tipi di azione
(`AzioneTipo`, `scout-store.ts`: `attacco`, `ace`, `muro`, `errore`, `punto_avv`, `errore_avv`),
i totali `totaliPerGiocatore()` e il campo `scout_partite.azioni` restano nel codice e nei dati:
leggono le partite scoutate prima di questa modifica, ma oggi nessuna schermata crea nuove azioni.
---
## Valutazioni dei fondamentali (− / +)
Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro
([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai
totali P/A/M/E. Si seleziona il giocatore e si tocca − o + sul fondamentale: il titolo della sezione
mostra cognome e numero del giocatore e il set in corso, e accanto a ogni tasto compare il conteggio
di quel giocatore **nel set in corso**. «Annulla» nella sezione toglie l'ultima valutazione del set in
corso (l'«Annulla» in alto agisce solo sulle azioni, quindi oggi è sempre spento).
Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una
lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()`
(`scout-store.ts`). Lo stato in corso le salva in `scout_live.stato.valutazioni` (assente negli
stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_partite.valutazioni`.
--- ---
## Fine partita ## Fine partita
Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutazione, e
`finePartita()` archivia anche la partita fatta di sole valutazioni: il set in corso entra nei
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde
«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**:
`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
referto CSI; resta visibile il «Report tecnico» con il CSV.
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato) `scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
e rilascia la sessione. e rilascia la sessione.
@@ -84,9 +119,10 @@ e rilascia la sessione.
## Export CSV ## Export CSV
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per `scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per giocatore e
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita, log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
sezione "Report tecnico". i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
pagina partita, sezione "Report tecnico".
--- ---
@@ -107,7 +143,7 @@ calcolati a runtime).
## Regole rispettate ## Regole rispettate
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout - **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri e valutazioni) restano confinati allo Scout
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
classifiche individuali. classifiche individuali.
@@ -115,10 +151,18 @@ calcolati a runtime).
## Limiti noti ## Limiti noti
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita»
entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
- 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 - Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
garanzia è il lock di sessione (vedi sopra). garanzia è il lock di sessione (vedi sopra).
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock: - Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
lettura e upsert non sono atomici. lettura e upsert non sono atomici.
- Avversario e campo non si correggono dallo scout.
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale. - `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due - Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
partite cadono lo stesso giorno. partite cadono lo stesso giorno.
@@ -127,4 +171,6 @@ calcolati a runtime).
## Evoluzioni possibili ## Evoluzioni possibili
- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin)
e, nello scout, in sola lettura quelle dei set chiusi.
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.5.0", "version": "1.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.5.0", "version": "1.6.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.5.0", "version": "1.6.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+56 -18
View File
@@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi";
import { import {
conteggioPartita, conteggioPartita,
mioVoto, mioVoto,
mvpPartita,
ORE_ATTESA_MVP, ORE_ATTESA_MVP,
useMieiVotiMvp,
useVotaMvp, useVotaMvp,
useVotiMvp, useVotiMvp,
VOTI_MINIMI_MVP,
votoMvpAperto, votoMvpAperto,
type VotoMvp, votoMvpChiuso,
DURATA_VOTO_MVP_ORE,
type VotoMvpPubblico,
} from "@/lib/mvp-voti"; } 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 * 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 * 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). // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const voti = useVotiMvp(); const voti = useVotiMvp();
const miei = useMieiVotiMvp(io?.id);
const vota = useVotaMvp(); const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id)); const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false); 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 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 totale = conteggio.reduce((s, c) => s + c.voti, 0);
const testa = conteggio[0]; const mvp = mvpPartita(tutti, matchId);
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti; const puoVotare = !!io && !mio && !chiuso && presente(io.id);
const puoVotare = !!io && presente(io.id);
if (!votoMvpAperto(evento.data, evento.ora)) { if (!votoMvpAperto(evento.data, evento.ora)) {
return ( return (
@@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
votato_nome: nome, votato_nome: nome,
}); });
setAperto(false); setAperto(false);
setScelto(null);
toast.success(`Voto MVP registrato: ${nome}`); toast.success(`Voto MVP registrato: ${nome}`);
} catch { } catch {
toast.error("Voto non riuscito, riprova"); toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
} }
} }
@@ -78,36 +87,53 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"} <Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
</p> </p>
{mio || chiuso ? null : (
<button <button
type="button" type="button"
onClick={() => setAperto((v) => !v)} onClick={() => setAperto((v) => !v)}
disabled={!puoVotare} disabled={!puoVotare}
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50" className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
> >
{mio ? "Cambia voto" : "Vota"} Vota
</button> </button>
)}
</div> </div>
<p className="mt-2 text-[13px]"> <p className="mt-2 text-[13px]">
{testa && !pareggio ? ( {mvp.length > 0 ? (
<span className="inline-flex items-center gap-1 font-bold"> <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti}) {mvp.map((c) => (
<span key={c.id} className="inline-flex items-center gap-1">
<Crown className="h-3.5 w-3.5 text-warning" /> {c.nome} ({c.voti})
</span>
))}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{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`}
</span> </span>
)} )}
</p> </p>
{mio ? ( {mio ? (
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p> <p className="mt-1 text-[13px] text-muted-foreground">
Hai votato {mio.votato_nome}: il voto non si può cambiare.
</p>
) : chiuso ? (
<p className="mt-1 text-[13px] text-muted-foreground">Votazione chiusa.</p>
) : !puoVotare ? ( ) : !puoVotare ? (
<p className="mt-1 text-[13px] text-muted-foreground"> <p className="mt-1 text-[13px] text-muted-foreground">
Vota chi era presente a questa partita. Vota chi era presente a questa partita.
</p> </p>
) : null} ) : (
<p className="mt-1 text-[13px] text-muted-foreground">
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
</p>
)}
{aperto ? ( {aperto && !mio ? (
<>
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto"> <div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato {/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
anche dal vincolo `mvp_no_autovoto` (M12). */} anche dal vincolo `mvp_no_autovoto` (M12). */}
@@ -118,10 +144,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
key={g.id} key={g.id}
type="button" type="button"
disabled={vota.isPending} disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))} onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
className={cn( className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
mio?.votato_id === g.id scelto?.id === g.id
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "bg-card text-foreground", : "bg-card text-foreground",
)} )}
@@ -130,6 +156,18 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
</button> </button>
))} ))}
</div> </div>
<button
type="button"
disabled={!scelto || vota.isPending}
onClick={() => scelto && invia(scelto.id, scelto.nome)}
className="mt-2 w-full rounded-full bg-accent px-3 py-2 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{scelto ? `Conferma il voto per ${scelto.nome}` : "Scegli chi votare"}
</button>
<p className="mt-1 text-center text-[13px] text-muted-foreground">
Il voto è definitivo: non si può cambiare.
</p>
</>
) : conteggio.length > 0 ? ( ) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{conteggio.map((c) => ( {conteggio.map((c) => (
+11 -1
View File
@@ -536,6 +536,7 @@ export type Database = {
parziali: Json; parziali: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni: Json;
}; };
Insert: { Insert: {
avversario: string; avversario: string;
@@ -548,6 +549,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni?: Json;
}; };
Update: { Update: {
avversario?: string; avversario?: string;
@@ -560,6 +562,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro?: number; set_loro?: number;
set_nostri?: number; set_nostri?: number;
valutazioni?: Json;
}; };
Relationships: []; Relationships: [];
}; };
@@ -625,7 +628,14 @@ export type Database = {
}; };
}; };
Views: { Views: {
[_ in never]: never; mvp_voti_anonimi: {
Row: {
match_id: string | null;
votato_id: string | null;
votato_nome: string | null;
};
Relationships: [];
};
}; };
Functions: { Functions: {
has_role: { has_role: {
+77 -45
View File
@@ -8,52 +8,91 @@ export type VotoMvp = {
votato_nome: string; votato_nome: string;
}; };
/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */
export type VotoMvpPubblico = Pick<VotoMvp, "match_id" | "votato_id" | "votato_nome">;
const CHIAVE = ["mvp-voti"] as const; 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. */ /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
export const ORE_ATTESA_MVP = 2; 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 { export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`); const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false; if (Number.isNaN(inizio.getTime())) return false;
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000; 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. */ * Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
export function useVotiMvp() { export function useVotiMvp() {
return useQuery({ return useQuery({
queryKey: CHIAVE, queryKey: CHIAVE,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvpPubblico[]> => {
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<VotoMvp[]> => { queryFn: async (): Promise<VotoMvp[]> => {
const { data, error } = await supabase const { data, error } = await supabase
.from("mvp_voti") .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; if (error) throw error;
return (data ?? []) as VotoMvp[]; return (data ?? []) as VotoMvp[];
}, },
}); });
} }
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
export function useVotaMvp() { export function useVotaMvp() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (voto: VotoMvp) => { mutationFn: async (voto: VotoMvp) => {
const { error } = await supabase const { error } = await supabase.from("mvp_voti").insert(voto);
.from("mvp_voti")
.upsert(voto, { onConflict: "match_id,votante_id" });
if (error) throw error; if (error) throw error;
return voto; return voto;
}, },
// Aggiorna la cache localmente: nessuna rilettura dal database. // Aggiorna la cache localmente: nessuna rilettura dal database.
onSuccess: (voto) => { onSuccess: (voto) => {
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => { qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
const altri = (prec ?? []).filter( ...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id), voto,
); ]);
return [...altri, voto]; qc.setQueryData<VotoMvpPubblico[]>(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; export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */ /** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>(); const map = new Map<string, ConteggioMvp>();
for (const v of voti) { for (const v of voti) {
if (v.match_id !== matchId) continue; 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)); 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<string, string> { * MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
const perMatch = new Map<string, VotoMvp[]>(); * merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
for (const v of voti) { */
const arr = perMatch.get(v.match_id) ?? []; export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
arr.push(v);
perMatch.set(v.match_id, arr);
}
const out: Record<string, string> = {};
for (const [matchId] of perMatch) {
const top = conteggioPartita(voti, matchId); const top = conteggioPartita(voti, matchId);
const totaleVoti = top.reduce((s, c) => s + c.voti, 0); 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) return [];
if ( return top.filter((c) => c.voti === top[0]!.voti);
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
out[matchId] = top[0]!.nome;
} }
/** «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<string, string> {
const out: Record<string, string> = {};
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; 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 * MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti * parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
* restituisce una mappa vuota. * restituisce una mappa vuota.
*/ */
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> { export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id)); for (const matchId of new Set(voti.map((v) => v.match_id))) {
for (const matchId of matchIds) { for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
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;
}
} }
return out; return out;
} }
+2 -1
View File
@@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string {
export function useAnagraficaRosa({ export function useAnagraficaRosa({
conAllenatori = false, conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array< }: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
> { > {
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
return useMemo( return useMemo(
@@ -44,6 +44,7 @@ export function useAnagraficaRosa({
.map((g) => ({ .map((g) => ({
id: g.id, id: g.id,
nome: nomeCompleto(g), nome: nomeCompleto(g),
cognome: g.cognome,
ruolo: g.ruolo, ruolo: g.ruolo,
numero: g.numero, numero: g.numero,
nascita: g.nascita ?? "", nascita: g.nascita ?? "",
+33 -1
View File
@@ -1,5 +1,11 @@
import type { Giocatore } from "./crapp-data"; import type { Giocatore } from "./crapp-data";
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; import {
azioniMeta,
contaValutazioni,
fondamentali,
totaliPerGiocatore,
type ScoutMatch,
} from "./scout-store";
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ /** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
export function rigaCsv(campi: Array<string | number>) { export function rigaCsv(campi: Array<string | number>) {
@@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
if (!t) continue; if (!t) continue;
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
} }
if (match.valutazioni.length > 0) {
righe.push("");
righe.push(
rigaCsv([
"Set",
"Numero",
"Giocatore",
...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]),
]),
);
const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y);
for (const set of sets) {
for (const g of rosa) {
if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue;
const c = contaValutazioni(match.valutazioni, g.id, set);
righe.push(
rigaCsv([
set,
g.numero,
g.nome,
...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]),
]),
);
}
}
}
righe.push(""); righe.push("");
righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
for (const a of match.azioni) { for (const a of match.azioni) {
+4 -1
View File
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import type { Azione } from "./scout-store"; import type { Azione, Valutazione } from "./scout-store";
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */ /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
export type StatoScout = { export type StatoScout = {
azioni: Azione[]; azioni: Azione[];
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
valutazioni?: Valutazione[];
setChiusi: Array<[number, number]>; setChiusi: Array<[number, number]>;
avversario: string; avversario: string;
casa: boolean; casa: boolean;
@@ -12,6 +14,7 @@ export type StatoScout = {
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({ export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
azioni: [], azioni: [],
valutazioni: [],
setChiusi: [], setChiusi: [],
avversario, avversario,
casa, casa,
+80 -1
View File
@@ -60,6 +60,72 @@ export type Azione = {
ts: number; ts: number;
}; };
/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */
export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro";
export const fondamentali: Array<{ id: Fondamentale; label: string }> = [
{ id: "battuta", label: "Battuta" },
{ id: "ricezione", label: "Ricezione" },
{ id: "attacco", label: "Attacco" },
{ id: "muro", label: "Muro" },
];
export type Valutazione = {
id: string;
giocatoreId: string;
fondamentale: Fondamentale;
segno: "+" | "-";
set: number;
ts: number;
};
export type ConteggioValutazioni = Record<Fondamentale, { piu: number; meno: number }>;
/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */
export function contaValutazioni(
valutazioni: Valutazione[],
giocatoreId: string,
set?: number,
): ConteggioValutazioni {
const out = {} as ConteggioValutazioni;
for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 };
for (const v of valutazioni) {
if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue;
out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1;
}
return out;
}
/**
* I + di un giocatore in un set, esclusa la ricezione (che qui non vale come punto messo a
* terra): quello che compare nel suo riquadro dello scout.
*/
export function contaPiuSenzaRicezione(
valutazioni: Valutazione[],
giocatoreId: string,
set: number,
): number {
return valutazioni.filter(
(v) =>
v.giocatoreId === giocatoreId &&
v.set === set &&
v.segno === "+" &&
v.fondamentale !== "ricezione",
).length;
}
/**
* Parziali da archiviare: i set chiusi più il set in corso, se ha qualcosa dentro (punti o
* valutazioni). Senza punteggio un set vale 0-0: serve solo a numerarlo.
*/
export function parzialiFinali(
setChiusi: Array<[number, number]>,
corrente: [number, number],
haDati: boolean,
): Array<[number, number]> {
return haDati ? [...setChiusi, corrente] : setChiusi;
}
export type ScoutMatch = { export type ScoutMatch = {
id: string; id: string;
data: string; data: string;
@@ -69,6 +135,7 @@ export type ScoutMatch = {
setLoro: number; setLoro: number;
parziali: Array<[number, number]>; parziali: Array<[number, number]>;
azioni: Azione[]; azioni: Azione[];
valutazioni: Valutazione[];
}; };
type RigaScoutPartita = { type RigaScoutPartita = {
@@ -80,6 +147,7 @@ type RigaScoutPartita = {
set_loro: number; set_loro: number;
parziali: unknown; parziali: unknown;
azioni: unknown; azioni: unknown;
valutazioni: unknown;
}; };
function daRiga(r: RigaScoutPartita): ScoutMatch { function daRiga(r: RigaScoutPartita): ScoutMatch {
@@ -92,6 +160,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
setLoro: r.set_loro, setLoro: r.set_loro,
parziali: (r.parziali as Array<[number, number]> | null) ?? [], parziali: (r.parziali as Array<[number, number]> | null) ?? [],
azioni: (r.azioni as Azione[] | null) ?? [], azioni: (r.azioni as Azione[] | null) ?? [],
valutazioni: (r.valutazioni as Valutazione[] | null) ?? [],
}; };
} }
@@ -100,7 +169,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
async function fetchScoutMatches(): Promise<ScoutMatch[]> { async function fetchScoutMatches(): Promise<ScoutMatch[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from("scout_partite") .from("scout_partite")
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni")
.order("creato_il", { ascending: false }); .order("creato_il", { ascending: false });
if (error) throw error; if (error) throw error;
return (data ?? []).map(daRiga); return (data ?? []).map(daRiga);
@@ -131,6 +200,7 @@ export function useSalvaScoutMatch() {
set_loro: input.match.setLoro, set_loro: input.match.setLoro,
parziali: JSON.parse(JSON.stringify(input.match.parziali)), parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)), azioni: JSON.parse(JSON.stringify(input.match.azioni)),
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
}); });
if (error) throw error; if (error) throw error;
return input.match; return input.match;
@@ -159,6 +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. */ /** Somma delle azioni di un match per giocatore. */
export function totaliPerGiocatore(azioni: Azione[]) { export function totaliPerGiocatore(azioni: Azione[]) {
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>(); const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
+2 -2
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { formatData, type RigaClassifica } from "@/lib/crapp-data"; import { formatData, type RigaClassifica } from "@/lib/crapp-data";
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits"; import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { useScoutMatches } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { import {
isNostraSquadra, isNostraSquadra,
@@ -150,7 +150,7 @@ function Classifica() {
const m = matchDaPartitaCsi(p); const m = matchDaPartitaCsi(p);
return { ...m, mvp: mvpPerData(m.data), scout: false }; return { ...m, mvp: mvpPerData(m.data), scout: false };
}) })
: scoutMatches.map((m) => ({ : scoutMatches.filter(haPunteggio).map((m) => ({
id: m.id, id: m.id,
data: m.data, data: m.data,
avversario: m.avversario, avversario: m.avversario,
+4 -2
View File
@@ -19,7 +19,7 @@ import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreBase } from "@/lib/user-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useScoutMatches } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
import { mediaVotoColpoDOcchio } from "@/lib/pagelle"; import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
@@ -63,7 +63,9 @@ function Index() {
const votiMvp = useVotiMvp(); const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []); const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const csiGiocate = csi ? partiteGiocate(csi.partite) : []; const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null); const ultimaBase = csiGiocate[0]
? matchDaPartitaCsi(csiGiocate[0])
: (scoutMatches.find(haPunteggio) ?? null);
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null; const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
const eventoUltima = ultimaBase const eventoUltima = ultimaBase
+3 -2
View File
@@ -11,7 +11,7 @@ import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/cr
import { Pagelle } from "@/components/crapp/Pagelle"; import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store"; import { useSonoAllenatore } from "@/lib/user-store";
@@ -70,7 +70,8 @@ function PartitaDetail() {
const csiMatch = csi const csiMatch = csi
? partiteGiocate(csi.partite).find((p) => p.data === evento.data) ? partiteGiocate(csi.partite).find((p) => p.data === evento.data)
: undefined; : undefined;
const match = scout const match =
scout && haPunteggio(scout)
? { ? {
id: scout.id, id: scout.id,
data: scout.data, data: scout.data,
+127 -161
View File
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Select } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
@@ -27,10 +27,15 @@ import {
} from "@/lib/scout-live"; } from "@/lib/scout-live";
import { import {
azioniMeta, azioniMeta,
contaPiuSenzaRicezione,
contaValutazioni,
parzialiFinali,
fondamentali,
useSalvaScoutMatch, useSalvaScoutMatch,
totaliPerGiocatore,
type Azione, type Azione,
type AzioneTipo, type AzioneTipo,
type Fondamentale,
type Valutazione,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({ export const Route = createFileRoute("/scout")({
@@ -52,8 +57,6 @@ export const Route = createFileRoute("/scout")({
component: Scout, component: Scout,
}); });
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({ function Blocco({
icona, icona,
titolo, titolo,
@@ -119,7 +122,11 @@ function Scout() {
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null; const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id; const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id;
const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0; const ripresa =
!!statoSalvato.data &&
(statoSalvato.data.azioni.length > 0 ||
(statoSalvato.data.valutazioni?.length ?? 0) > 0 ||
statoSalvato.data.setChiusi.length > 0);
if (!controllo) { if (!controllo) {
return ( return (
@@ -196,10 +203,12 @@ function ScoutBoard({
.trim() || "Avversario", .trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"), partita.titolo.trim().toLowerCase().startsWith("crap"),
); );
const [avversario, setAvversario] = useState(base.avversario); const [avversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa); const [casa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi); const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
const [azioni, setAzioni] = useState<Azione[]>(base.azioni); const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
const [valutazioni, setValutazioni] = useState<Valutazione[]>(base.valutazioni ?? []);
const [ordine, setOrdine] = useState<"cognome" | "numero">("cognome");
const [selezionato, setSelezionato] = useState<string | null>(null); const [selezionato, setSelezionato] = useState<string | null>(null);
const salva = useSalvaStatoScout(); const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout(); const cancella = useCancellaStatoScout();
@@ -208,52 +217,73 @@ function ScoutBoard({
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra. // Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
const rosa = useAnagraficaRosa(); const rosa = useAnagraficaRosa();
const finito = useRef(false); const finito = useRef(false);
const codaSalvataggio = useRef<Promise<unknown>>(Promise.resolve());
/** In campo solo chi ha confermato la presenza (anche in ritardo). */ /** In campo solo chi ha confermato la presenza (anche in ritardo). */
const convocati = useMemo(() => { const convocati = useMemo(() => {
const presenti = rosa.filter( const presenti = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
); );
return presenti.length > 0 ? presenti : rosa; const elenco = presenti.length > 0 ? presenti : rosa;
}, [risposte, rosa]); return [...elenco].sort((a, b) =>
ordine === "numero"
? a.numero - b.numero
: a.cognome.localeCompare(b.cognome, "it") || a.numero - b.numero,
);
}, [risposte, rosa, ordine]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui. // Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => { useEffect(() => {
if (finito.current) return; if (finito.current) return;
const id = window.setTimeout(() => { // Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si
void salva.mutateAsync({ // chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito
eventoId: partita.id, // rimette a posto tutto; la coda li tiene in ordine.
stato: { azioni, setChiusi, avversario, casa }, const stato = { azioni, valutazioni, setChiusi, avversario, casa };
codaSalvataggio.current = codaSalvataggio.current
.then(() => salva.mutateAsync({ eventoId: partita.id, stato }))
.then(() => {
toast.dismiss("scout-salvataggio");
})
.catch(() => {
toast.error("Salvataggio non riuscito: controlla la connessione", {
id: "scout-salvataggio",
duration: Infinity,
});
}); });
}, 800);
return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [azioni, setChiusi, avversario, casa, partita.id]); }, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]);
const setCorrente = setChiusi.length + 1; const setCorrente = setChiusi.length + 1;
const azioniSet = azioni.filter((a) => a.set === setCorrente); const azioniSet = azioni.filter((a) => a.set === setCorrente);
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
const puntiLoro = azioniSet.length - puntiNoi; const puntiLoro = azioniSet.length - puntiNoi;
const setNostri = setChiusi.filter(([n, l]) => n > l).length; const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
const setLoro = setChiusi.length - setNostri;
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
function registra(tipo: AzioneTipo) { function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
const meta = azioniMeta[tipo]; if (!selezionato) {
if (meta.richiedeGiocatore && !selezionato) {
toast.error("Seleziona prima il giocatore"); toast.error("Seleziona prima il giocatore");
return; return;
} }
setAzioni((prev) => { setValutazioni((prev) => [
const nuova: Azione = { ...prev,
{
id: `${Date.now()}-${prev.length}`, id: `${Date.now()}-${prev.length}`,
tipo, giocatoreId: selezionato,
fondamentale,
segno,
set: setCorrente, set: setCorrente,
ts: Date.now(), ts: Date.now(),
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}), },
}; ]);
return [...prev, nuova]; }
});
function annullaValutazione() {
const ultima = valutazioni[valutazioni.length - 1];
if (!ultima || ultima.set !== setCorrente) {
toast.error("Puoi annullare solo le valutazioni del set in corso");
return;
}
setValutazioni((prev) => prev.slice(0, -1));
} }
function annulla() { function annulla() {
@@ -266,17 +296,16 @@ function ScoutBoard({
} }
function chiudiSet() { function chiudiSet() {
if (puntiNoi === 0 && puntiLoro === 0) { if (!setHaDati) {
toast.error("Set senza azioni"); toast.error("Set senza valutazioni");
return; return;
} }
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]); setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`); toast.success(`Set ${setCorrente} chiuso`);
} }
async function finePartita() { async function finePartita() {
const parziali: Array<[number, number]> = const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
if (parziali.length === 0) { if (parziali.length === 0) {
toast.error("Nessun set da salvare"); toast.error("Nessun set da salvare");
return; return;
@@ -294,21 +323,24 @@ function ScoutBoard({
setLoro: parziali.length - vinti, setLoro: parziali.length - vinti,
parziali, parziali,
azioni, azioni,
valutazioni,
}, },
}); });
} catch { } catch {
toast.error("Salvataggio non riuscito, riprova"); toast.error("Salvataggio non riuscito, riprova");
return; return;
} }
toast.success("Partita salvata: ora la squadra può votare l'MVP"); toast.success("Partita salvata");
finito.current = true; finito.current = true;
void cancella.mutateAsync(partita.id); void cancella.mutateAsync(partita.id);
onFine(); onFine();
navigate({ to: "/squadra" }); navigate({ to: "/squadra" });
} }
const ultime = [...azioni].slice(-4).reverse(); const giocatoreSel = rosa.find((g) => g.id === selezionato);
const contiSelezionato = selezionato
? contaValutazioni(valutazioni, selezionato, setCorrente)
: null;
return ( return (
<> <>
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground"> <header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
@@ -334,45 +366,28 @@ function ScoutBoard({
</button> </button>
</div> </div>
</div> </div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<div className="min-w-0">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
{avversario || "Avversario"}
</p>
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
</div>
</div>
{setChiusi.length > 0 ? (
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
{setChiusi.map((p, i) => (
<span
key={i}
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[13px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
) : null}
</header> </header>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <div className="mb-2 flex items-center justify-between gap-2">
1. Tocca il giocatore <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Selezione giocatore
</p> </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"> <div className="grid grid-cols-4 gap-2">
{convocati.map((g) => { {convocati.map((g) => {
const attivo = selezionato === g.id; const attivo = selezionato === g.id;
const t = totali.get(g.id); const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
return ( return (
<button <button
key={g.id} key={g.id}
@@ -391,7 +406,7 @@ function ScoutBoard({
attivo ? "text-accent-foreground/70" : "text-muted-foreground", attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)} )}
> >
{t ? `${t.punti}p` : "0p"} +{piu}
</p> </p>
</button> </button>
); );
@@ -400,107 +415,58 @@ function ScoutBoard({
</section> </section>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <div className="mb-2 flex items-center justify-between gap-2">
2. Tocca l'azione
</p>
<div className="grid grid-cols-2 gap-2">
{ordineAzioni.map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
!selezionato && "opacity-50",
)}
>
{azioniMeta[tipo].label}
</button>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
)}
>
{azioniMeta[tipo].short}
</button>
))}
</div>
</section>
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-3 shadow-card">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
{setCorrente}
</p> </p>
{ultime.length === 0 ? ( <button
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p> type="button"
) : ( onClick={annullaValutazione}
<ul className="mt-2 space-y-1.5"> disabled={!valutazioni.some((v) => v.set === setCorrente)}
{ultime.map((a, i) => { className="inline-flex shrink-0 items-center gap-1 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
const g = rosa.find((x) => x.id === a.giocatoreId); >
<Undo2 className="h-3.5 w-3.5" /> Annulla
</button>
</div>
<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 ( return (
<li <div key={f.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-2">
key={a.id} <span className="text-[15px] font-bold">{f.label}</span>
className={cn( <button
"flex items-center justify-between gap-2 px-1 text-[13px]", type="button"
i === 0 && "anim-riga", 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="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span> − <span className="tabular-nums">{c.meno}</span>
<span </button>
className={cn( <button
"shrink-0 rounded-full px-2 py-0.5 text-[13px] font-bold uppercase", type="button"
azioniMeta[a.tipo].className, 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"
> >
{azioniMeta[a.tipo].short} + <span className="tabular-nums">{c.piu}</span>
</span> </button>
</li> </div>
); );
})} })}
</ul>
)}
</div> </div>
</section> </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"> <section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
<button <button
type="button" type="button"
@@ -0,0 +1,18 @@
-- M35 — Il voto MVP è definitivo (DD-051)
--
-- Prima un giocatore poteva cambiare o togliere il proprio voto (policy FOR ALL, M11/M13). Ora può
-- solo inserirlo: UPDATE e DELETE restano agli admin (policy «Gli admin gestiscono tutti i voti
-- MVP», M11), che possono correggere un voto sbagliato. Il vincolo UNIQUE (match_id, votante_id)
-- fa il resto: un secondo voto sulla stessa partita viene rifiutato.
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
@@ -0,0 +1,48 @@
-- M36 — Il voto MVP si controlla anche sulla presenza e sull'orario (DD-051)
--
-- Fino alla M35 il database controllava solo convocati e allenatori (`evento_permette_voto`): che
-- votante e votato fossero presenti, e che fossero passate 2 ore dall'inizio, lo faceva solo l'app.
-- Ora la policy di INSERT su `mvp_voti` usa `voto_mvp_consentito()`, che aggiunge:
-- - l'evento è una partita;
-- - votante e votato hanno risposto `presente` o `ritardo`;
-- - sono passate 2 ore dall'inizio (stesso valore di ORE_ATTESA_MVP in src/lib/mvp-voti.ts).
--
-- È una funzione nuova e non una modifica di `evento_permette_voto`, perché questa è usata anche da
-- pagelle e badge social, che non hanno queste regole. Gli admin restano fuori (policy M11): possono
-- ancora correggere. L'ora si legge in Europe/Rome; un `ora` vuoto o non valido vale mezzanotte,
-- come in `votoMvpAperto()`.
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
p_match_id text, p_votante_id text, p_votato_id text
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
AND EXISTS (
SELECT 1 FROM public.eventi_app e
WHERE e.id = p_match_id
AND e.tipo = 'partita'
AND ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
THEN e.ora::time ELSE time '00:00' END)
AT TIME ZONE 'Europe/Rome') + interval '2 hours' <= now()
)
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
AND (
SELECT count(DISTINCT r.giocatore_id)
FROM public.risposte_presenze r
WHERE r.evento_id = p_match_id
AND r.giocatore_id IN (p_votante_id, p_votato_id)
AND r.stato IN ('presente', 'ritardo')
) = 2;
$$;
DROP POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.voto_mvp_consentito(match_id, votante_id, votato_id)
);
@@ -0,0 +1,89 @@
-- M37 — Voto MVP segreto, nome del votato dal database, votazione che si chiude (DD-052)
--
-- 1. Segretezza. Finora `mvp_voti` era leggibile da tutti, quindi chiunque vedeva chi aveva votato chi.
-- Ora la lettura diretta della tabella è del solo votante (il proprio voto) e degli admin (policy
-- M11, che servono per correggere un voto: dall'app non vedono chi ha votato chi). Tutti gli altri
-- leggono solo i conteggi, dalla vista `mvp_voti_anonimi`: una riga per voto, senza `votante_id`.
-- 2. Nome. `votato_nome` lo scriveva il client, quindi poteva non corrispondere a `votato_id`. Un trigger
-- lo riempie dalla rosa (`giocatori_squadra`) a partire da `votato_id`, ignorando quello inviato.
-- 3. Chiusura. Il voto si apre 2 ore dopo l'inizio della partita (M36) e si chiude 24 ore dopo
-- l'apertura. I valori sono gli stessi di ORE_ATTESA_MVP e DURATA_VOTO_MVP_ORE in
-- src/lib/mvp-voti.ts. Gli admin restano fuori da questo vincolo.
-- --- 1. Segretezza ------------------------------------------------------------------------------
DROP POLICY "Chiunque puo leggere i voti MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno legge il proprio voto MVP" ON public.mvp_voti
FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
);
-- La vista gira con i permessi del proprietario (security_invoker = false): è ciò che le permette di
-- leggere tutte le righe pur senza esporre il votante. L'ordine è per candidato, non per arrivo, così
-- la sequenza dei voti non rivela nulla.
CREATE VIEW public.mvp_voti_anonimi WITH (security_invoker = false) AS
SELECT match_id, votato_id, votato_nome
FROM public.mvp_voti
ORDER BY match_id, votato_id;
REVOKE ALL ON public.mvp_voti_anonimi FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.mvp_voti_anonimi TO authenticated;
GRANT ALL ON public.mvp_voti_anonimi TO service_role;
-- --- 2. Nome del votato dalla rosa ----------------------------------------------------------------
-- Se `votato_id` non è in rosa (id di prova) resta il nome ricevuto: non c'è niente da sostituire.
CREATE OR REPLACE FUNCTION public.mvp_voti_nome_dalla_rosa()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
DECLARE
nome_rosa text;
BEGIN
SELECT btrim(g.nome || ' ' || g.cognome) INTO nome_rosa
FROM public.giocatori_squadra g
WHERE g.id = NEW.votato_id;
IF FOUND THEN
NEW.votato_nome := nome_rosa;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER mvp_voti_nome_dalla_rosa
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.mvp_voti
FOR EACH ROW
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
-- --- 3. Finestra di voto: apre a +2 ore, chiude 24 ore dopo l'apertura ------------------------------
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
p_match_id text, p_votante_id text, p_votato_id text
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
AND EXISTS (
SELECT 1
FROM public.eventi_app e
CROSS JOIN LATERAL (
SELECT ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
THEN e.ora::time ELSE time '00:00' END)
AT TIME ZONE 'Europe/Rome') AS inizio
) t
WHERE e.id = p_match_id
AND e.tipo = 'partita'
AND t.inizio + interval '2 hours' <= now()
AND now() < t.inizio + interval '2 hours' + interval '24 hours'
)
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
AND (
SELECT count(DISTINCT r.giocatore_id)
FROM public.risposte_presenze r
WHERE r.evento_id = p_match_id
AND r.giocatore_id IN (p_votante_id, p_votato_id)
AND r.stato IN ('presente', 'ritardo')
) = 2;
$$;
@@ -0,0 +1,11 @@
-- M38 — Anche i badge social prendono il nome del votato dalla rosa (DD-052)
--
-- Stesso difetto dell'MVP (M37): `badge_social_voti.votato_nome` lo scrive il client, quindi può non
-- corrispondere a `votato_id`, e l'app lo mostra («Hai votato …», elenco dei più votati). Si riusa il
-- trigger della M37: le due tabelle hanno le stesse colonne `votato_id` e `votato_nome`.
-- Le pagelle non hanno un nome del votato, quindi non serve altro.
CREATE TRIGGER badge_social_voti_nome_dalla_rosa
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.badge_social_voti
FOR EACH ROW
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
@@ -0,0 +1,10 @@
-- M39 — Scout Live: valutazioni − / + per fondamentale.
--
-- Durante la partita lo scout può segnare, per ogni giocatore e per ogni set, un − o un +
-- su battuta, ricezione, attacco e muro. Non sono punti: restano fuori dal punteggio e da
-- `azioni`. Lo stato in corso le porta dentro `scout_live.stato` (jsonb, nessuna modifica);
-- la partita archiviata le conserva in questa colonna, accanto alle azioni.
-- Solo additiva: le partite già salvate ricevono l'elenco vuoto.
ALTER TABLE public.scout_partite
ADD COLUMN valutazioni jsonb NOT NULL DEFAULT '[]'::jsonb;
+2 -4
View File
@@ -62,10 +62,8 @@ try {
const csi = (await json(res)) as DatiCsi; const csi = (await json(res)) as DatiCsi;
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra)); const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale"); assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok( // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata.
csi.partite.some((p) => p.setNostri !== null), assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale");
"ci sono risultati giocati",
);
const html = await pagina("/classifica"); const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/); assert.match(titolo(html), /Classifica campionato/);
+29
View File
@@ -0,0 +1,29 @@
/**
* Data e ora (fuso Europe/Rome) di un istante a `ore` ore da adesso, nel formato di `eventi_app`
* (`data` AAAA-MM-GG, `ora` HH:MM). Serve ai test del voto MVP, che dipende dall'orario di inizio.
*/
export function romaTra(ore: number): { data: string; ora: string } {
const p: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(Date.now() + ore * 3_600_000))
.map((x) => [x.type, x.value]),
);
return { data: `${p["year"]}-${p["month"]}-${p["day"]}`, ora: `${p["hour"]}:${p["minute"]}` };
}
/**
* Una data con ora vuota vale mezzanotte. Ritorna il giorno la cui mezzanotte cade tra 2 e 26 ore
* fa (il voto è aperto): oggi se sono passate almeno 2 ore dalla mezzanotte, altrimenti ieri.
*/
export function giornoSenzaOraAperto(): string {
const adesso = romaTra(0);
return Number(adesso.ora.slice(0, 2)) >= 2 ? adesso.data : romaTra(-24).data;
}
+8 -5
View File
@@ -124,9 +124,13 @@ try {
assert.ok(Date.now() - t0 < 2_000, "risposta immediata"); assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
}); });
await provaCsi( // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti", // del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile.
async () => { const nomeDettaglio = "GET /api/public/csi-partita/$id restituisce formazioni e precedenti";
if (csiRaggiungibile && !partiteGiocate(csi?.partite ?? [])[0]) {
salta(nomeDettaglio, "nessuna gara ancora giocata nella stagione");
} else
await provaCsi(nomeDettaglio, async () => {
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0]; const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio"); assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
@@ -147,8 +151,7 @@ try {
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(secondo.status, 200); assert.equal(secondo.status, 200);
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata"); assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
}, });
);
await provaCsi( await provaCsi(
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe", "GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
+12 -14
View File
@@ -14,7 +14,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges"; import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -52,11 +52,11 @@ if (!locale) {
} }
/** Rilegge esattamente come `useVotiMvp()`. */ /** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> { async function leggiVoti(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
@@ -68,8 +68,8 @@ if (!locale) {
await prova( await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database", "il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => { async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, // voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge. // non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({ await upsert({
match_id: `${PREFISSO}-m1`, match_id: `${PREFISSO}-m1`,
@@ -107,17 +107,15 @@ if (!locale) {
votato_id: "vg1", votato_id: "vg1",
votato_nome: "Uno", votato_nome: "Uno",
}); });
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti(); const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti); const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)"); assert.equal(
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); vinti["vg1"],
2,
"vg1 vince m1 e m2, m3 ha un solo voto (sotto quorum, non conta)",
);
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0)); const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
+28 -1
View File
@@ -8,6 +8,7 @@
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato), * Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`. * lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
*/ */
import { romaTra } from "../helpers/roma";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -259,8 +260,34 @@ if (!locale) {
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque. // Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ convocati: [] }), // M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
}); });
// M36: perché il rifiuto dipenda solo dal ruolo, tutti sono presenti (lo slot dell'allenatore
// lo segniamo presente a mano: da sé non potrebbe rispondere) e la partita è passata.
for (const g of [GIOCATORE, SLOT, "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: g, stato: "presente" }),
});
}
// Controllo positivo: due giocatori presenti, il voto passa.
const valido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: GIOCATORE,
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(
await righeToccate(valido),
1,
`un giocatore vota un giocatore (${valido.status})`,
);
const vota = await rest("mvp_voti", tokenAllenatore, { const vota = await rest("mvp_voti", tokenAllenatore, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
+365 -10
View File
@@ -16,6 +16,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale(); const locale = statoLocale();
@@ -310,12 +311,47 @@ if (!locale) {
}), }),
}); });
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
});
// --- Voto MVP: definitivo (M35), solo tra presenti e dopo 2 ore dall'inizio (M36) ----------
let contatoreMvp = 0;
/** Crea una partita (o altro tipo) con le presenze indicate; ritorna l'id. */
async function eventoMvp(
quando: { data: string; ora: string },
presenze: Record<string, string>,
tipo = "partita",
): Promise<string> {
const id = `${PREFISSO}-mvp-${++contatoreMvp}`;
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ id, tipo, titolo: "Voto MVP", luogo: "Palestra", ...quando }),
});
assert.equal(await righeToccate(creato), 1, "l'evento di prova si crea");
for (const [giocatore_id, stato] of Object.entries(presenze)) {
const r = await rest("risposte_presenze", SERVIZIO, {
method: "POST",
body: JSON.stringify({ evento_id: id, giocatore_id, stato }),
});
assert.ok(r.ok, `presenza di prova ${giocatore_id}: ${r.status}`);
}
return id;
}
const votaMvp = (token: string, match_id: string, votante_id: string, votato_id: string) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id, votato_id, votato_nome: votato_id }),
});
const passata = romaTra(-3);
await prova("il voto MVP è uno solo, definitivo e firmato (M35)", async () => {
const EVENTO_MVP = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const mioMvp = await rest("mvp_voti", tokenGiocatore, { const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g1", votante_id: "g1",
votato_id: "g5", votato_id: "g5",
votato_nome: "Cinque", votato_nome: "Cinque",
@@ -323,11 +359,55 @@ if (!locale) {
}); });
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra"); assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
// Il voto MVP è definitivo (M35): né un secondo voto, né una modifica, né la cancellazione.
const secondo = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
votante_id: "g1",
votato_id: "g6",
votato_nome: "Sei",
}),
});
assert.ok(
!secondo.ok,
`un secondo voto MVP sulla stessa partita è rifiutato (${secondo.status})`,
);
const modifica = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Sei" }),
},
);
assert.equal(await righeToccate(modifica), 0, "il proprio voto MVP non si modifica");
const cancella = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "DELETE",
headers: { Prefer: "return=representation" },
},
);
assert.equal(await righeToccate(cancella), 0, "il proprio voto MVP non si cancella");
const dopo = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1&select=votato_id`,
SERVIZIO,
);
assert.deepEqual(
await dopo.json(),
[{ votato_id: "g5" }],
"il voto è rimasto quello iniziale",
);
const mvp = await rest("mvp_voti", tokenGiocatore, { const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g5", votante_id: "g5",
votato_id: "g1", votato_id: "g1",
votato_nome: "Uno", votato_nome: "Uno",
@@ -336,6 +416,265 @@ if (!locale) {
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`); assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
}); });
await prova("il voto MVP vale solo tra presenti (M36)", async () => {
const q = passata;
// Controllo positivo: presente e in ritardo possono votare e farsi votare.
const ok = await eventoMvp(q, { g1: "presente", g5: "ritardo" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, ok, "g1", "g5")), 1);
const okRitardo = await eventoMvp(q, { g1: "ritardo", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, okRitardo, "g1", "g5")), 1);
for (const [stato, nome] of [
["forse", "forse"],
["assente", "assente"],
["infortunato", "infortunato"],
[null, "senza risposta"],
] as const) {
const votatoNo = await eventoMvp(q, {
g1: "presente",
...(stato ? { g5: stato } : {}),
});
const r = await votaMvp(tokenGiocatore, votatoNo, "g1", "g5");
assert.ok(!r.ok, `un votato ${nome} non si può votare (${r.status})`);
const votanteNo = await eventoMvp(q, {
g5: "presente",
...(stato ? { g1: stato } : {}),
});
const r2 = await votaMvp(tokenGiocatore, votanteNo, "g1", "g5");
assert.ok(!r2.ok, `un votante ${nome} non può votare (${r2.status})`);
}
});
await prova(
"il voto MVP apre 2 ore dopo l'inizio e chiude 24 ore dopo (M36, M37)",
async () => {
const presenti = { g1: "presente", g5: "presente" };
const tre = await eventoMvp(romaTra(-3), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, tre, "g1", "g5")),
1,
"3 ore dopo",
);
const una = await eventoMvp(romaTra(-1), presenti);
const rUna = await votaMvp(tokenGiocatore, una, "g1", "g5");
assert.ok(!rUna.ok, `1 ora dopo l'inizio: ancora chiuso (${rUna.status})`);
const venticinque = await eventoMvp(romaTra(-25), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, venticinque, "g1", "g5")),
1,
"25 ore dopo l'inizio: ancora aperto (chiude a 26)",
);
const ventisette = await eventoMvp(romaTra(-27), presenti);
const rChiuso = await votaMvp(tokenGiocatore, ventisette, "g1", "g5");
assert.ok(!rChiuso.ok, `27 ore dopo l'inizio: chiuso (${rChiuso.status})`);
const futura = await eventoMvp(romaTra(48), presenti);
const rFutura = await votaMvp(tokenGiocatore, futura, "g1", "g5");
assert.ok(!rFutura.ok, `partita non ancora giocata (${rFutura.status})`);
// Un'ora vuota vale mezzanotte, come nell'app.
const senzaOra = await eventoMvp({ data: giornoSenzaOraAperto(), ora: "" }, presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, senzaOra, "g1", "g5")),
1,
"senza ora vale mezzanotte",
);
},
);
await prova("il voto MVP esiste solo per le partite (M36)", async () => {
const presenti = { g1: "presente", g5: "presente" };
for (const tipo of ["allenamento", "evento"]) {
const e = await eventoMvp(passata, presenti, tipo);
const r = await votaMvp(tokenGiocatore, e, "g1", "g5");
assert.ok(!r.ok, `nessun MVP per ${tipo} (${r.status})`);
}
});
await prova("l'admin può registrare un voto MVP anche fuori da queste regole", async () => {
const futura = await eventoMvp(romaTra(48), { g1: "assente" });
const r = await votaMvp(tokenAdmin, futura, "g1", "g5");
assert.equal(await righeToccate(r), 1, "l'admin corregge/inserisce senza vincoli di M36");
const chiusa = await eventoMvp(romaTra(-48), { g1: "assente" });
const rc = await votaMvp(tokenAdmin, chiusa, "g1", "g5");
assert.equal(await righeToccate(rc), 1, "l'admin può votare anche a votazione chiusa");
});
await prova("il voto MVP è segreto: nessuno legge chi ha votato chi (M37)", async () => {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, e, "g1", "g5")), 1);
// Un secondo voto di un altro votante (inserito dall'admin) per avere due righe.
assert.equal(await righeToccate(await votaMvp(tokenAdmin, e, "g5", "g1")), 1);
const tabella = await rest(
`mvp_voti?match_id=eq.${e}&select=votante_id,votato_id`,
tokenGiocatore,
);
assert.deepEqual(
await tabella.json(),
[{ votante_id: "g1", votato_id: "g5" }],
"dalla tabella un giocatore legge solo il proprio voto",
);
const vista = await rest(`mvp_voti_anonimi?match_id=eq.${e}&select=*`, tokenGiocatore);
assert.ok(vista.ok, `la vista dei conteggi si legge (${vista.status})`);
const righe = (await vista.json()) as Record<string, string>[];
assert.deepEqual(
righe.map((r) => r["votato_id"]).sort(),
["g1", "g5"],
"ci sono tutti i voti",
);
assert.ok(
righe.every((r) => !("votante_id" in r)),
"ma la vista non dice chi li ha dati",
);
const anonimo = await rest(`mvp_voti_anonimi?match_id=eq.${e}`, ANON);
assert.ok(!anonimo.ok, `senza account non si legge nemmeno la vista (${anonimo.status})`);
const anonimoTabella = await rest(`mvp_voti?match_id=eq.${e}`, ANON);
const corpo = anonimoTabella.ok ? ((await anonimoTabella.json()) as unknown[]) : [];
assert.equal(corpo.length, 0, "né la tabella");
const admin = await rest(`mvp_voti?match_id=eq.${e}&select=votante_id`, tokenAdmin);
assert.equal(
((await admin.json()) as unknown[]).length,
2,
"l'admin vede i voti dalla tabella: gli servono per correggerli",
);
});
/** Nome in rosa di uno slot, come lo compone il trigger (nome + cognome, senza spazi in testa). */
async function nomeInRosa(id: string): Promise<string> {
const r = await rest(`giocatori_squadra?id=eq.${id}&select=nome,cognome`, SERVIZIO);
const [g] = (await r.json()) as { nome: string; cognome: string }[];
return `${g!.nome} ${g!.cognome}`.trim();
}
const votoNome = (token: string, match_id: string, votato_id: string, resto: object) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id: "g1", votato_id, ...resto }),
});
const nomeRiga = async (res: Response) =>
((await res.json()) as { votato_nome: string }[])[0]?.votato_nome;
await prova("MVP: il nome del votato lo mette il database, non il client (M37)", async () => {
const g5 = await nomeInRosa("g5");
// Nome inventato: ignorato.
const falso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(
await votoNome(tokenGiocatore, falso, "g5", { votato_nome: "Nome Inventato" }),
),
g5,
);
const visto = await rest(
`mvp_voti_anonimi?match_id=eq.${falso}&select=votato_nome`,
tokenGiocatore,
);
assert.deepEqual(await visto.json(), [{ votato_nome: g5 }], "e così lo vede la squadra");
// Nome omesso (la colonna è NOT NULL): lo riempie il trigger, il voto passa.
const omesso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, omesso, "g5", {})),
g5,
"nome omesso",
);
// Nome vuoto, con spazi, con un altro giocatore: sempre quello della rosa.
for (const [i, strano] of ["", " ", await nomeInRosa("g1"), "<b>x</b>"].entries()) {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, e, "g5", { votato_nome: strano })),
g5,
`caso ${i}`,
);
}
// Nome già corretto: resta lo stesso.
const giusto = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, giusto, "g5", { votato_nome: g5 })),
g5,
);
});
await prova(
"MVP: l'admin che corregge un voto non può far divergere il nome (M37)",
async () => {
const [g5, g6] = [await nomeInRosa("g5"), await nomeInRosa("g6")];
const e = await eventoMvp(passata, { g1: "presente", g5: "presente", g6: "presente" });
await votoNome(tokenGiocatore, e, "g5", {});
const filtro = `mvp_voti?match_id=eq.${e}&votante_id=eq.g1`;
// Cambia solo il nome: torna quello della rosa.
const soloNome = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(soloNome), g5, "nome riscritto da solo");
// Cambia il votato: il nome lo segue.
const altro = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(altro), g6, "nome che segue il nuovo votato");
},
);
await prova("MVP: un id fuori rosa conserva il nome ricevuto (M37)", async () => {
// Solo la service role (come i test sui badge) può scrivere id che non sono in rosa.
const e = `${PREFISSO}-mvp-fuori-rosa`;
const r = await rest("mvp_voti", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: e,
votante_id: "xx1",
votato_id: "xx2",
votato_nome: "Ospite",
}),
});
assert.equal(await nomeRiga(r), "Ospite", "niente da sostituire: resta il nome ricevuto");
});
await prova("badge social: il nome del votato lo mette il database (M38)", async () => {
const g5 = await nomeInRosa("g5");
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const base = { match_id: e, categoria: "sorriso", votante_id: "g1", votato_id: "g5" };
const post = (resto: object) =>
rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ ...base, ...resto }),
});
assert.equal(
await nomeRiga(await post({ votato_nome: "Nome Inventato" })),
g5,
"nome inventato",
);
// Stesso voto rinviato con un altro nome (upsert): il nome resta quello della rosa.
assert.equal(
await nomeRiga(await post({ votato_nome: "Un Altro" })),
g5,
"upsert con altro nome",
);
const omesso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ ...base, categoria: "meme" }),
});
assert.equal(await nomeRiga(omesso), g5, "nome omesso");
});
await prova("le cacche le dichiara il diretto interessato", async () => { await prova("le cacche le dichiara il diretto interessato", async () => {
const mie = await rest("cacche_partita", tokenGiocatore, { const mie = await rest("cacche_partita", tokenGiocatore, {
method: "POST", method: "POST",
@@ -455,8 +794,8 @@ if (!locale) {
id: EVENTO_CON_G1, id: EVENTO_CON_G1,
tipo: "partita", tipo: "partita",
titolo: "Partita con g1 convocato, g2 no", titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
convocati: ["g1", "g5"], convocati: ["g1", "g5"],
}), }),
@@ -482,9 +821,17 @@ if (!locale) {
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui // Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo // convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto. // (es. un evento inesistente) e sembrare comunque corretto.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CON_G1, giocatore_id: g, stato: "presente" }),
});
}
const votoValido = await rest("mvp_voti", tokenGiocatore, { const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CON_G1, match_id: EVENTO_CON_G1,
votante_id: "g1", votante_id: "g1",
@@ -503,8 +850,8 @@ if (!locale) {
id: EVENTO_CHIUSO, id: EVENTO_CHIUSO,
tipo: "partita", tipo: "partita",
titolo: "Partita con pagelle chiuse", titolo: "Partita con pagelle chiuse",
data: "2026-01-03", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
pagelle_chiuse: true, pagelle_chiuse: true,
}), }),
@@ -529,9 +876,17 @@ if (!locale) {
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di // Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano // "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento. // votabili sullo stesso evento.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CHIUSO, giocatore_id: g, stato: "presente" }),
});
}
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, { const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CHIUSO, match_id: EVENTO_CHIUSO,
votante_id: "g1", votante_id: "g1",
+4 -4
View File
@@ -20,7 +20,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
@@ -67,11 +67,11 @@ if (!locale) {
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
} }
async function leggiMvp(): Promise<VotoMvp[]> { async function leggiMvp(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
async function leggiPagelle(): Promise<VotoPagella[]> { async function leggiPagelle(): Promise<VotoPagella[]> {
+27
View File
@@ -32,6 +32,7 @@ if (!locale) {
"turni_palloni", "turni_palloni",
"risposte_presenze", "risposte_presenze",
"scout_live", "scout_live",
"scout_partite",
] as const; ] as const;
const rest = (percorso: string, init?: RequestInit) => const rest = (percorso: string, init?: RequestInit) =>
@@ -312,6 +313,32 @@ if (!locale) {
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi", "il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
); );
}); });
// --- scout: le valutazioni − / + stanno accanto alle azioni (m39) ----------------
await prova(
"la partita scoutata conserva le valutazioni; senza, l'elenco è vuoto",
async () => {
const evento = `${PREFISSO}-e5`;
const base = {
evento_id: evento,
data: "2026-11-02",
avversario: "X",
set_nostri: 3,
set_loro: 0,
};
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s1` });
const valutazioni = [
{ id: "v1", giocatoreId: "g1", fondamentale: "battuta", segno: "+", set: 1, ts: 0 },
];
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s2`, valutazioni });
const righe = await leggi(
"scout_partite",
`evento_id=eq.${evento}&select=id,valutazioni&order=id`,
);
assert.deepEqual(righe[0]?.["valutazioni"], [], "le partite senza valutazioni hanno []");
assert.deepEqual(righe[1]?.["valutazioni"], valutazioni);
},
);
} finally { } finally {
for (const tabella of TABELLE) { for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id"; const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
+71 -7
View File
@@ -1,11 +1,18 @@
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { import {
conteggioPartita, conteggioPartita,
elencoNomi,
mioVoto, mioVoto,
mvpPartita,
mvpVintiPerGiocatore, mvpVintiPerGiocatore,
DURATA_VOTO_MVP_ORE,
ORE_ATTESA_MVP,
vincitoriMvp, vincitoriMvp,
votoMvpAperto, votoMvpAperto,
votoMvpChiuso,
votoMvpInCorso,
VOTI_MINIMI_MVP, VOTI_MINIMI_MVP,
type VotoMvp, type VotoMvp,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
@@ -44,9 +51,13 @@ assert.deepEqual(
["Anna", "Zeno"], ["Anna", "Zeno"],
); );
// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- // --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); assert.deepEqual(
vincitoriMvp(pari),
{ m3: "Anna e Zeno" },
"due voti pari in testa: sono MVP entrambi, a pari merito",
);
assert.deepEqual(vincitoriMvp([]), {}); assert.deepEqual(vincitoriMvp([]), {});
// --- vincitoriMvp: quorum minimo di voti ------------------------------------- // --- vincitoriMvp: quorum minimo di voti -------------------------------------
@@ -69,13 +80,38 @@ const triplaPari = [
v("m6", "g2", "g8", "Anna"), v("m6", "g2", "g8", "Anna"),
v("m6", "g3", "g7", "Bea"), v("m6", "g3", "g7", "Bea"),
]; ];
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); assert.deepEqual(
vincitoriMvp(triplaPari),
{ m6: "Anna, Bea e Zeno" },
"tre pari in testa: tre MVP, in ordine alfabetico",
);
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore(triplaPari), mvpVintiPerGiocatore(triplaPari),
{}, { g9: 1, g8: 1, g7: 1 },
"stessa parità: nessuna vittoria netta da contare", "ognuno dei pari merito conta un titolo",
); );
// Primo pari col secondo, terzo staccato: il terzo resta fuori.
const duePariUnoSotto = [
v("m7", "g1", "g9", "Zeno"),
v("m7", "g2", "g9", "Zeno"),
v("m7", "g3", "g8", "Anna"),
v("m7", "g4", "g8", "Anna"),
v("m7", "g5", "g7", "Bea"),
];
assert.deepEqual(
mvpPartita(duePariUnoSotto, "m7").map((c) => c.nome),
["Anna", "Zeno"],
);
assert.deepEqual(mvpPartita([v("m8", "g1", "g2", "Solo")], "m8"), [], "sotto quorum: nessuno");
assert.deepEqual(mvpPartita([], "m8"), []);
// --- elencoNomi ---
assert.equal(elencoNomi([]), "");
assert.equal(elencoNomi(["Anna"]), "Anna");
assert.equal(elencoNomi(["Anna", "Bruno"]), "Anna e Bruno");
assert.equal(elencoNomi(["Anna", "Bruno", "Carla"]), "Anna, Bruno e Carla");
// --- mioVoto ----------------------------------------------------------------- // --- mioVoto -----------------------------------------------------------------
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
@@ -89,8 +125,8 @@ assert.deepEqual(
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, ...pari]), mvpVintiPerGiocatore([...partita, ...pari]),
{ g2: 1, g5: 1 }, { g2: 1, g5: 1, g9: 1, g8: 1 },
"la parità non assegna", "m3 è pari: i due pari merito prendono un titolo ciascuno",
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
@@ -114,4 +150,32 @@ assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita pa
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte"); assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true); assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
// --- votoMvpChiuso: 24 ore dopo l'apertura (2 ore dopo l'inizio) -----------------
assert.equal(DURATA_VOTO_MVP_ORE, 24);
const giorno = (g: number, o: number, m = 0) => new Date(2026, 8, g, o, m);
// partita del 5/9 alle 20:30: apre il 5 alle 22:30, chiude il 6 alle 22:30
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 29)), false, "un minuto prima");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 30)), true, "24 ore in punto");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(20, 12)), true, "partita vecchia");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(5, 12)), false, "prima della partita");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 21)), false, "non ancora aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 23)), true, "aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(7, 8)), false, "chiuso");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 2)), true);
// --- l'app e il database (M36, M37) hanno la stessa finestra -----------------------
const m37 = readFileSync(
"supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql",
"utf8",
);
assert.ok(
m37.includes(`interval '${ORE_ATTESA_MVP} hours'`),
"ORE_ATTESA_MVP e l'attesa di voto_mvp_consentito() devono coincidere",
);
assert.ok(
m37.includes(`interval '${DURATA_VOTO_MVP_ORE} hours'`),
"DURATA_VOTO_MVP_ORE e la chiusura di voto_mvp_consentito() devono coincidere",
);
console.log("mvp-voti: ok"); console.log("mvp-voti: ok");
+40
View File
@@ -29,6 +29,7 @@ const match: ScoutMatch = {
[25, 18], [25, 18],
], ],
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")], azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
valutazioni: [],
}; };
const csv = csvScoutMatch(match, giocatori); const csv = csvScoutMatch(match, giocatori);
@@ -78,3 +79,42 @@ const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }, giocat
assert.ok(conVirgolette.includes('"Team ""X"""')); assert.ok(conVirgolette.includes('"Team ""X"""'));
console.log("scout-export: ok"); console.log("scout-export: ok");
// --- valutazioni − / + per set -------------------------------------------------
assert.ok(!csv.includes("Battuta +"), "senza valutazioni la sezione non c'è");
const val = (
giocatoreId: string,
fondamentale: "battuta" | "muro",
segno: "+" | "-",
set: number,
) => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
const conVal = csvScoutMatch(
{
...match,
valutazioni: [
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "-", 1),
val(g1.id, "muro", "-", 2),
],
},
giocatori,
);
assert.ok(
conVal.includes(
"Set;Numero;Giocatore;Battuta +;Battuta -;Ricezione +;Ricezione -;Attacco +;Attacco -;Muro +;Muro -",
),
);
assert.ok(
conVal.includes(`1;${g1.numero};${g1.nome};2;1;0;0;0;0;0;0`),
"set 1: 2 più e 1 meno in battuta",
);
assert.ok(conVal.includes(`2;${g1.numero};${g1.nome};0;0;0;0;0;0;0;1`), "set 2: 1 meno a muro");
assert.ok(!conVal.includes(`;${g2.nome};0;0;0;0;0;0;0;0`), "chi non ha valutazioni non ha riga");
+5 -1
View File
@@ -5,7 +5,10 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati ----------- // --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
const stato = statoIniziale("Avversari", true); const stato = statoIniziale("Avversari", true);
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`. // Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true }); assert.deepEqual(
{ ...stato },
{ azioni: [], valutazioni: [], setChiusi: [], avversario: "Avversari", casa: true },
);
const trasferta = statoIniziale("Altra Squadra", false); const trasferta = statoIniziale("Altra Squadra", false);
assert.equal(trasferta.casa, false); assert.equal(trasferta.casa, false);
@@ -14,6 +17,7 @@ assert.deepEqual(trasferta.azioni, [], "ogni chiamata parte da una lista vuota i
// Le liste non devono essere condivise tra due stati distinti. // Le liste non devono essere condivise tra due stati distinti.
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 }); stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro"); assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro");
assert.notEqual(stato.valutazioni, trasferta.valutazioni, "anche le valutazioni sono indipendenti");
// --- SCOUT_STATO_KEY: chiave stabile per React Query -------------------------- // --- SCOUT_STATO_KEY: chiave stabile per React Query --------------------------
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]); assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
+113
View File
@@ -3,10 +3,16 @@ import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data"; import { giocatori } from "@/lib/crapp-data";
import { import {
azioniMeta, azioniMeta,
contaPiuSenzaRicezione,
haPunteggio,
parzialiFinali,
contaValutazioni,
fondamentali,
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
type Azione, type Azione,
type Valutazione,
type ScoutMatch, type ScoutMatch,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
@@ -32,6 +38,7 @@ const match = (
setLoro, setLoro,
parziali: [], parziali: [],
azioni, azioni,
valutazioni: [],
}); });
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------ // --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
@@ -84,3 +91,109 @@ for (const [tipo, meta] of Object.entries(azioniMeta)) {
} }
console.log("scout-store: ok"); console.log("scout-store: ok");
// --- valutazioni − / +: per giocatore, fondamentale e set; fuori dal punteggio -------
const v = (
giocatoreId: string,
fondamentale: Valutazione["fondamentale"],
segno: Valutazione["segno"],
set = 1,
): Valutazione => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
assert.deepEqual(
fondamentali.map((f) => f.id),
["battuta", "ricezione", "attacco", "muro"],
);
const val = [
v("g1", "battuta", "+"),
v("g1", "battuta", "+"),
v("g1", "battuta", "-"),
v("g1", "ricezione", "-", 2),
v("g1", "muro", "+", 2),
v("g2", "attacco", "+"),
];
assert.deepEqual(contaValutazioni(val, "g1", 1), {
battuta: { piu: 2, meno: 1 },
ricezione: { piu: 0, meno: 0 },
attacco: { piu: 0, meno: 0 },
muro: { piu: 0, meno: 0 },
});
assert.deepEqual(
contaValutazioni(val, "g1", 2).ricezione,
{ piu: 0, meno: 1 },
"il set 2 è separato",
);
assert.deepEqual(
contaValutazioni(val, "g1").battuta,
{ piu: 2, meno: 1 },
"senza set: tutta la partita",
);
assert.deepEqual(contaValutazioni(val, "g1").ricezione, { piu: 0, meno: 1 });
assert.deepEqual(
contaValutazioni(val, "g3", 1).battuta,
{ piu: 0, meno: 0 },
"giocatore senza valutazioni",
);
assert.equal(totaliPerGiocatore([]).size, 0, "le valutazioni non sono azioni: nessun punto");
// --- + del riquadro giocatore: tutti i fondamentali tranne la ricezione, nel set -------
const piuVal = [
v("g1", "battuta", "+"),
v("g1", "attacco", "+"),
v("g1", "muro", "+"),
v("g1", "ricezione", "+"),
v("g1", "attacco", "-"),
v("g1", "battuta", "+", 2),
v("g2", "attacco", "+"),
];
assert.equal(
contaPiuSenzaRicezione(piuVal, "g1", 1),
3,
"niente ricezione, niente −, solo il set 1",
);
assert.equal(contaPiuSenzaRicezione(piuVal, "g1", 2), 1);
assert.equal(contaPiuSenzaRicezione(piuVal, "g3", 1), 0);
// --- parziali finali: il set in corso conta solo se ha dati ------------------------
assert.deepEqual(
parzialiFinali([[25, 20]], [0, 0], true),
[
[25, 20],
[0, 0],
],
"set con sole valutazioni",
);
assert.deepEqual(
parzialiFinali([[25, 20]], [0, 0], false),
[[25, 20]],
"set vuoto: non si archivia",
);
assert.deepEqual(parzialiFinali([], [0, 0], false), [], "nessun set da salvare");
// --- haPunteggio: le partite di sole valutazioni non sono un risultato -------------
assert.equal(
haPunteggio({
parziali: [
[0, 0],
[0, 0],
],
}),
false,
);
assert.equal(haPunteggio({ parziali: [] }), false);
assert.equal(
haPunteggio({
parziali: [
[0, 0],
[25, 18],
],
}),
true,
);