Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b93f92965 | ||
|
|
c98e1ef218 | ||
|
|
e6cfb1bf00 | ||
|
|
30fb8e23b6 | ||
|
|
91ac7ce65c | ||
|
|
a7b9affbd6 | ||
|
|
7acdc2ba1e | ||
|
|
c1627a0720 | ||
|
|
63d1f01be6 | ||
|
|
2787672cd5 | ||
|
|
1c63808bfb | ||
|
|
92ce3979a3 | ||
|
|
252e1528f6 | ||
|
|
ebafb98b32 | ||
|
|
d86b38d47b | ||
|
|
74c78faac5 | ||
|
|
5fd7c895b3 | ||
|
|
44194d6d54 | ||
|
|
51b7a10c99 | ||
|
|
09dba1890d | ||
|
|
9d047a6ecd | ||
|
|
5fed105850 | ||
|
|
127d5cb5b8 | ||
|
|
e5fac24916 | ||
|
|
b17605ae76 | ||
|
|
92e7195357 | ||
|
|
215090d297 |
No files matched your search
+20
-15
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 06/10/2026
|
||||
Ultimo aggiornamento: 02/11/2026
|
||||
|
||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||
@@ -9,28 +9,33 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
||||
|
||||
## Stato generale
|
||||
|
||||
- **Versione:** 1.5.0 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — campionato CSI sulla
|
||||
stagione 2026/27 (nuovi `project_id` e `team_id`, nessuna Coppa). La 1.4.2: ogni evento può avere un link
|
||||
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con
|
||||
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo
|
||||
loro. La 1.4.0: avviso «Presenza modificata» ad admin e
|
||||
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
|
||||
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
|
||||
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
|
||||
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||
- **Versione:** 1.6.1 su `main`: risultati dello scout leggibili nella pagina
|
||||
partita, classifica cacche completa e pagina partita a sezioni a scomparsa, senza migration. La 1.6.0
|
||||
(tag `v1.6.0`, in produzione dal 02/11/2026): voto MVP definitivo, segreto e a
|
||||
tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
|
||||
nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
|
||||
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
|
||||
vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
|
||||
avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
|
||||
mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
|
||||
lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le
|
||||
presenze sui convocati, gli avvisi in Home ridisegnati e i caratteri più grandi. Dettaglio in
|
||||
[docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il
|
||||
26/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
|
||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||
- **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
|
||||
applicate tutte e 50 (`npx supabase db push` del 02/11/2026: `m35`-`m39`, verificato con
|
||||
`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
|
||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||
è su `main` dalla 1.3.0.
|
||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
||||
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
||||
collega al primo accesso (DD-018).
|
||||
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
- **Lavoro in corso:** nessuno su branch. Le voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
|
||||
«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
|
||||
|
||||
|
||||
@@ -13,6 +13,77 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.6.1] - 2026-11-06
|
||||
|
||||
Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni
|
||||
a scomparsa. Nessuna migration.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Pagina partita, sezione «Scout»: i risultati dello scout si leggono nell'app, non solo nel CSV
|
||||
admin. Per ogni fondamentale: percentuale di +, barra verde/rossa e conteggi di squadra; selettore
|
||||
«Totale» e «Set 1»…«Set 5» e tabella per giocatore con + / −. Il pulsante ⓘ apre una guida su come
|
||||
leggere i dati. Le azioni (punti, ace, muri, errori) restano in un blocco a parte per le partite
|
||||
scoutate col metodo precedente; il CSV resta per gli admin.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Sondaggio cacche: la classifica completa è visibile a tutti i giocatori (non più solo il podio),
|
||||
anche a sondaggio chiuso, con posizioni a pari merito senza salti (1°, 1°, 2°), barra
|
||||
proporzionale e la propria riga evidenziata.
|
||||
- Pagina partita: formazioni, badge, sondaggio, pagelle, scout e rosa sono sezioni a scomparsa
|
||||
(aperti all'inizio solo rosa e presenze e, nella sua finestra, il sondaggio). Le formazioni
|
||||
indicano la fonte «Livescore CSI»; lo scout mostra il numero di valutazioni. I titoli delle
|
||||
sezioni a scomparsa hanno la stessa dimensione delle altre e le chiuse non lasciano vuoto sotto.
|
||||
- Pagelle di fine partita: non compaiono più i convocati che risultano assenti o infortunati a
|
||||
quella partita (solo filtro di interfaccia).
|
||||
- Pagina partita: la sezione «Report tecnico» si chiama «Scout».
|
||||
|
||||
## [1.6.0] - 2026-11-02
|
||||
|
||||
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
|
||||
applicate in produzione il 02/11/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro,
|
||||
separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il
|
||||
CSV admin della partita include una tabella con i conteggi per set.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
|
||||
blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set
|
||||
([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le
|
||||
partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita.
|
||||
- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
|
||||
se non riesce, compare un avviso.
|
||||
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di
|
||||
ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti.
|
||||
- Scout live: dopo «Fine partita» il messaggio dice solo «Partita salvata» (il voto MVP apre 2 ore
|
||||
dopo l'inizio della partita, non dipende dallo scout).
|
||||
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
|
||||
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
|
||||
|
||||
Campionato CSI 2026/27. Nessuna migration.
|
||||
|
||||
+21
-21
@@ -12,21 +12,21 @@ allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
|
||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
|
||||
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
|
||||
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
|
||||
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
|
||||
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
|
||||
@@ -88,18 +88,18 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. |
|
||||
|
||||
## Votazioni
|
||||
|
||||
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
|
||||
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. |
|
||||
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). |
|
||||
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
|
||||
+1
-1
@@ -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). |
|
||||
| **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). |
|
||||
| **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)). |
|
||||
| **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). |
|
||||
|
||||
+2
-2
@@ -34,6 +34,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
- [x] Collegamento CSI (stagione 2026/27)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato:
|
||||
a inizio stagione il calendario c'è prima dei risultati
|
||||
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
@@ -54,8 +56,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
|
||||
## Prossimo
|
||||
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -87,6 +87,9 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
||||
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
|
||||
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
|
||||
| [DD-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live |
|
||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||
|
||||
@@ -230,7 +230,8 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
|
||||
- **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
|
||||
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
|
||||
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
|
||||
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
|
||||
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
|
||||
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.
|
||||
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
|
||||
|
||||
+38
-19
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge`
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +16,10 @@ calcolato a runtime.
|
||||
## Dati
|
||||
|
||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||
partita, sovrascrivibile.
|
||||
partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il
|
||||
proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`,
|
||||
`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a
|
||||
partire da `votato_id`: il nome inviato dal client è ignorato.
|
||||
|
||||
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
|
||||
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
|
||||
@@ -27,44 +31,59 @@ restano nel database ma non vengono più letti da nessuna schermata).
|
||||
|
||||
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
|
||||
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
|
||||
quando aprirà. Nessun legame con il risultato caricato.
|
||||
quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura
|
||||
(DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il
|
||||
pulsante «Vota». Nessun legame con il risultato caricato.
|
||||
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
|
||||
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
|
||||
tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X».
|
||||
- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di
|
||||
confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il
|
||||
giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin.
|
||||
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
|
||||
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
|
||||
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
||||
- `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
|
||||
(DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
|
||||
- `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
|
||||
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||
incoronare nessuno, nemmeno senza concorrenza.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
|
||||
(anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse:
|
||||
chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database.
|
||||
- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di
|
||||
database vedono chi ha votato chi; l'app non lo mostra a nessuno.
|
||||
- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una
|
||||
possibile evoluzione.
|
||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
|
||||
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
|
||||
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||
partita.
|
||||
= tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
|
||||
controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
|
||||
sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
|
||||
vale mezzanotte). Sono le stesse
|
||||
regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
|
||||
e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
|
||||
(`m37`); un test unitario li tiene allineati. Gli
|
||||
admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo
|
||||
al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta.
|
||||
- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa,
|
||||
invece, assegna l'MVP a tutti i pari merito.
|
||||
- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
||||
- Nascondere i risultati finché la votazione non è chiusa.
|
||||
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
|
||||
@@ -26,6 +26,8 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
partita (scout salvato o dato CSI).
|
||||
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
|
||||
dal vincolo DB.
|
||||
- Nell'elenco da votare non compaiono i convocati che hanno risposto «assente» o «infortunato»
|
||||
alle presenze: solo filtro di interfaccia in `partita.$id.tsx`, il vincolo DB non cambia.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
|
||||
+70
-17
@@ -1,6 +1,6 @@
|
||||
# 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`,
|
||||
`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`
|
||||
@@ -10,9 +10,9 @@
|
||||
## Obiettivo
|
||||
|
||||
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
|
||||
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su
|
||||
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
|
||||
aggiornato da qualunque dispositivo.
|
||||
un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato,
|
||||
per set, con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che chi
|
||||
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
|
||||
`evento_id`, quindi un solo detentore).
|
||||
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
|
||||
mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `scout_live` — stato in corso (valutazioni, set chiusi, avversario e campo) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni,
|
||||
valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
|
||||
(`UNIQUE evento_id, giocatore_id`).
|
||||
|
||||
@@ -65,17 +65,52 @@ perché occupava spazio 6 giorni su 7.
|
||||
|
||||
## Cosa registra
|
||||
|
||||
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
|
||||
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
|
||||
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
|
||||
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
|
||||
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
|
||||
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
|
||||
La schermata `/scout` ha due passi: **Selezione giocatore** e **Valuta** (vedi sotto), seguiti da
|
||||
«Chiudi set» e «Fine partita».
|
||||
|
||||
- **Selezione giocatore:** griglia dei convocati (chi ha risposto "presente"/"ritardo", con fallback
|
||||
a tutta la rosa se nessuno ha risposto), ordinata per cognome o per numero con il selettore a
|
||||
destra del titolo (default cognome). In ogni riquadro, sotto il nome, compaiono i **+** del
|
||||
giocatore nel set in corso, ricezione esclusa (`contaPiuSenzaRicezione()`).
|
||||
- **Salvataggio** su `scout_live` a ogni cambiamento, senza attesa: i salvataggi vanno in coda, uno
|
||||
alla volta e in ordine, e ognuno scrive lo stato intero (uno riuscito rimedia a uno fallito). Se
|
||||
un salvataggio non riesce compare un avviso fisso, che sparisce al salvataggio successivo riuscito.
|
||||
|
||||
**Punteggio e azioni.** L'interfaccia non registra più il punteggio: sono stati tolti i tasti delle
|
||||
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
|
||||
|
||||
Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutazione, e
|
||||
`finePartita()` archivia anche la partita fatta di sole valutazioni: il set in corso entra nei
|
||||
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde
|
||||
«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**:
|
||||
`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
|
||||
referto CSI; resta visibile il «Scout» con il CSV.
|
||||
|
||||
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
|
||||
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
|
||||
e rilascia la sessione.
|
||||
@@ -84,9 +119,21 @@ e rilascia la sessione.
|
||||
|
||||
## Export CSV
|
||||
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
|
||||
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
|
||||
sezione "Report tecnico".
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per giocatore e
|
||||
log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
|
||||
i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
|
||||
pagina partita, sezione "Scout".
|
||||
|
||||
---
|
||||
|
||||
## Lettura nell'app
|
||||
|
||||
La sezione «Scout» della pagina partita (`ScoutReport.tsx`, visibile a chi vede la pagina; il CSV
|
||||
resta per i soli admin) mostra, per la partita o per un singolo set (selettore in alto, se i set
|
||||
sono più di uno): per ciascun fondamentale la percentuale di **+**, una barra verde/rossa e i
|
||||
conteggi di squadra; poi la tabella per giocatore con **+ / −** di ogni fondamentale. I totali
|
||||
vengono da `sintesiValutazioni()` (`scout-store.ts`). Le azioni (P/A/M/E) compaiono in un blocco a
|
||||
parte solo se la partita ne ha, cioè quelle scoutate prima di DD-053. Il pulsante (icona i) apre una guida in linea su valutazioni, percentuale, tabella e set.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,7 +154,7 @@ calcolati a runtime).
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **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
|
||||
classifiche individuali.
|
||||
|
||||
@@ -115,10 +162,15 @@ calcolati a runtime).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita»
|
||||
entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
|
||||
- Lo scout in corso mostra solo il set in corso: le valutazioni dei set chiusi si rivedono solo
|
||||
nella pagina partita, dopo «Fine partita».
|
||||
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
|
||||
garanzia è il lock di sessione (vedi sopra).
|
||||
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
|
||||
lettura e upsert non sono atomici.
|
||||
- Avversario e campo non si correggono dallo scout.
|
||||
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
|
||||
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
|
||||
partite cadono lo stesso giorno.
|
||||
@@ -127,4 +179,5 @@ calcolati a runtime).
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Nello scout, in sola lettura, le valutazioni dei set chiusi.
|
||||
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crapp",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.1",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import { BarChart3, Download, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import {
|
||||
fondamentali,
|
||||
sintesiValutazioni,
|
||||
totaliPerGiocatore,
|
||||
totaliSquadra,
|
||||
type ScoutMatch,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
const pct = (piu: number, meno: number) =>
|
||||
piu + meno === 0 ? null : Math.round((piu / (piu + meno)) * 100);
|
||||
|
||||
/** Lettura dello scout di una partita: squadra per fondamentale, set per set, dettaglio per giocatore. */
|
||||
export function ScoutReport({
|
||||
scout,
|
||||
rosa,
|
||||
admin,
|
||||
}: {
|
||||
scout: ScoutMatch;
|
||||
rosa: Giocatore[];
|
||||
admin: boolean;
|
||||
}) {
|
||||
// undefined = tutta la partita.
|
||||
const [set, setSet] = useState<number | undefined>(undefined);
|
||||
const [guida, setGuida] = useState(false);
|
||||
const sets = [...new Set(scout.valutazioni.map((v) => v.set))].sort((a, b) => a - b);
|
||||
const { squadra, perGiocatore } = sintesiValutazioni(scout.valutazioni, set);
|
||||
const righe = perGiocatore
|
||||
.map((r) => ({ ...r, g: rosa.find((x) => x.id === r.giocatoreId) }))
|
||||
.filter((r): r is typeof r & { g: Giocatore } => !!r.g)
|
||||
.sort((a, b) => a.g.numero - b.g.numero);
|
||||
|
||||
const azioni = totaliPerGiocatore(scout.azioni);
|
||||
const totaliAzioni = totaliSquadra([scout]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Valutazione scout
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={guida}
|
||||
aria-label="Come leggere lo scout"
|
||||
onClick={() => setGuida((g) => !g)}
|
||||
className={cn(
|
||||
"premi flex h-8 w-8 items-center justify-center rounded-full transition-colors",
|
||||
guida ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{guida ? (
|
||||
<dl className="mt-3 space-y-3 rounded-2xl bg-secondary p-4 text-[13px] leading-relaxed">
|
||||
{[
|
||||
[
|
||||
"Cosa misura",
|
||||
<>
|
||||
Durante la partita lo scout assegna a ciascun giocatore una valutazione{" "}
|
||||
<b className="text-success">+</b> (esecuzione positiva) o{" "}
|
||||
<b className="text-destructive">−</b> (esecuzione negativa) su quattro fondamentali:
|
||||
battuta, ricezione, attacco e muro. Sono giudizi qualitativi, non punti: non
|
||||
incidono su risultato, classifiche o badge.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Indicatori di squadra",
|
||||
<>
|
||||
Ogni scheda riporta, per fondamentale, la percentuale di valutazioni positive (+ ÷
|
||||
(+ + −)), la barra di composizione verde/rossa e il numero di + e −. Un valore alto
|
||||
indica un fondamentale eseguito con continuità.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Dettaglio per giocatore",
|
||||
<>
|
||||
La tabella elenca, per ogni giocatore, i + e i − di ciascun fondamentale nel formato{" "}
|
||||
<b>+ / −</b>: «3 / 1» indica tre valutazioni positive e una negativa. Compaiono solo
|
||||
i giocatori con almeno una valutazione nel periodo selezionato.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Periodo",
|
||||
<>
|
||||
«Totale» aggrega l'intera partita; «Set 1», «Set 2»… isolano un singolo set e
|
||||
permettono di osservare l'andamento durante la gara.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Interpretazione",
|
||||
<>
|
||||
Il campione è ridotto: con pochi tocchi la percentuale è poco significativa (un solo
|
||||
+ su un solo tocco vale 100%). I dati riflettono il giudizio dello scout in servizio
|
||||
e vanno letti come indicazione di squadra, non come voto individuale.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Partite precedenti",
|
||||
<>
|
||||
Per le partite scoutate con il metodo precedente è mostrato anche il riepilogo delle
|
||||
azioni registrate (punti, ace, muri, errori).
|
||||
</>,
|
||||
],
|
||||
].map(([titolo, testo]) => (
|
||||
<div key={titolo as string}>
|
||||
<dt className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo}
|
||||
</dt>
|
||||
<dd className="mt-0.5">{testo}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{scout.valutazioni.length > 0 ? (
|
||||
<>
|
||||
{sets.length > 1 ? (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Periodo"
|
||||
className="mt-3 grid grid-flow-col auto-cols-fr gap-1 rounded-2xl bg-secondary p-1"
|
||||
>
|
||||
{[undefined, ...sets].map((s) => (
|
||||
<button
|
||||
key={s ?? "tutta"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={s === set}
|
||||
onClick={() => setSet(s)}
|
||||
className={cn(
|
||||
"min-w-0 truncate rounded-xl px-1 py-2 text-[13px] font-bold uppercase transition-colors",
|
||||
s === set
|
||||
? "bg-primary text-primary-foreground shadow-card"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{s === undefined ? "Totale" : `Set ${s}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{fondamentali.map((f) => {
|
||||
const { piu, meno } = squadra[f.id];
|
||||
const p = pct(piu, meno);
|
||||
return (
|
||||
<div key={f.id} className="rounded-2xl bg-secondary p-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{f.label}
|
||||
</p>
|
||||
<p className="font-display text-xl leading-none">
|
||||
{p === null ? "—" : `${p}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-border">
|
||||
<span className="bg-success" style={{ width: `${p ?? 0}%` }} />
|
||||
<span
|
||||
className="bg-destructive"
|
||||
style={{ width: p === null ? 0 : `${100 - p}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] font-semibold tabular-nums">
|
||||
<span className="text-success">+{piu}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">·</span>
|
||||
<span className="text-destructive">−{meno}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Dettaglio giocatori
|
||||
</p>
|
||||
{righe.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Nessuna valutazione in questo set.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-x-auto rounded-2xl bg-secondary/60">
|
||||
<table className="w-full min-w-[22rem] text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[13px] uppercase text-muted-foreground">
|
||||
<th className="px-3 py-2 font-bold">Giocatore</th>
|
||||
{fondamentali.map((f) => (
|
||||
<th key={f.id} className="px-2 py-2 text-center font-bold">
|
||||
{f.label.slice(0, 3)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{righe.map(({ g, conteggi }) => (
|
||||
<tr key={g.id}>
|
||||
<td className="max-w-40 truncate px-3 py-2 font-semibold">
|
||||
<span className="mr-1.5 tabular-nums text-muted-foreground">
|
||||
#{g.numero}
|
||||
</span>
|
||||
{g.nome}
|
||||
</td>
|
||||
{fondamentali.map((f) => (
|
||||
<td key={f.id} className="px-2 py-2 text-center font-semibold tabular-nums">
|
||||
<span className="text-success">{conteggi[f.id].piu}</span>
|
||||
<span className="text-muted-foreground"> / </span>
|
||||
<span className="text-destructive">{conteggi[f.id].meno}</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Valutazioni: <span className="text-success">+</span> positive /{" "}
|
||||
<span className="text-destructive">−</span> negative. La percentuale è la quota di +.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{scout.azioni.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Azioni registrate
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ l: "Punti", v: totaliAzioni.punti },
|
||||
{ l: "Ace", v: totaliAzioni.ace },
|
||||
{ l: "Muri", v: totaliAzioni.muri },
|
||||
{ l: "Errori", v: totaliAzioni.errori },
|
||||
].map((t) => (
|
||||
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
{t.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{[...azioni.entries()].map(([gid, t]) => {
|
||||
const g = rosa.find((x) => x.id === gid);
|
||||
if (!g) return null;
|
||||
return (
|
||||
<div
|
||||
key={gid}
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||
#{g.numero} {g.nome}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{scout.valutazioni.length === 0 && scout.azioni.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Nessun dato registrato per questa partita.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout, rosa))
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
</button>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -40,11 +40,15 @@ export function SondaggioCacche({
|
||||
const media = mediaPartita(righe, eventoId);
|
||||
const classifica = [...dellaPartita]
|
||||
.sort((a, b) => b.quantita - a.quantita)
|
||||
.slice(0, 3)
|
||||
.map((r) => {
|
||||
const g = squadra.find((g) => g.id === r.giocatore_id);
|
||||
return { ...r, nome: g ? nomeCompleto(g) : "—" };
|
||||
});
|
||||
})
|
||||
// Pari merito = stessa posizione, senza salti (1°, 1°, 2°).
|
||||
.map((r, _, tutti) => ({
|
||||
...r,
|
||||
pos: new Set(tutti.filter((t) => t.quantita > r.quantita).map((t) => t.quantita)).size + 1,
|
||||
}));
|
||||
|
||||
async function rispondi(quantita: number) {
|
||||
if (!io) return;
|
||||
@@ -89,6 +93,7 @@ export function SondaggioCacche({
|
||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||
</p>
|
||||
<Classifica righe={classifica} io={io?.id} media={media} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -126,19 +131,7 @@ export function SondaggioCacche({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
{classifica.map((r, i) => (
|
||||
<span
|
||||
key={r.giocatore_id}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
|
||||
>
|
||||
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Classifica righe={classifica} io={io?.id} media={media} />
|
||||
|
||||
{admin ? (
|
||||
<button
|
||||
@@ -154,3 +147,61 @@ export function SondaggioCacche({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDAGLIE = ["🥇", "🥈", "🥉"];
|
||||
|
||||
type RigaClassifica = { giocatore_id: string; nome: string; quantita: number; pos: number };
|
||||
|
||||
function Classifica({
|
||||
righe,
|
||||
io,
|
||||
media,
|
||||
}: {
|
||||
righe: RigaClassifica[];
|
||||
io: string | undefined;
|
||||
media: number | string;
|
||||
}) {
|
||||
if (righe.length === 0) return null;
|
||||
const max = Math.max(...righe.map((r) => r.quantita), 1);
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-[13px]">
|
||||
<span className="font-bold uppercase tracking-wide text-muted-foreground">Classifica</span>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="divide-y divide-border overflow-hidden rounded-2xl bg-secondary/60">
|
||||
{righe.map((r) => (
|
||||
<li
|
||||
key={r.giocatore_id}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 px-3 py-2.5",
|
||||
r.giocatore_id === io && "bg-accent/15",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-0 bg-accent/10"
|
||||
style={{ width: `${(r.quantita / max) * 100}%` }}
|
||||
/>
|
||||
<span className="relative w-7 text-center text-[15px] font-bold tabular-nums text-muted-foreground">
|
||||
{r.pos <= 3 ? MEDAGLIE[r.pos - 1] : `${r.pos}°`}
|
||||
</span>
|
||||
<span className="relative min-w-0 flex-1 truncate text-[15px] font-semibold">
|
||||
{r.nome}
|
||||
{r.giocatore_id === io ? (
|
||||
<span className="ml-1.5 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
tu
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="relative text-[17px] font-bold tabular-nums">
|
||||
{r.quantita === 5 ? "5+" : r.quantita}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
conteggioPartita,
|
||||
mioVoto,
|
||||
mvpPartita,
|
||||
ORE_ATTESA_MVP,
|
||||
useMieiVotiMvp,
|
||||
useVotaMvp,
|
||||
useVotiMvp,
|
||||
VOTI_MINIMI_MVP,
|
||||
votoMvpAperto,
|
||||
type VotoMvp,
|
||||
votoMvpChiuso,
|
||||
DURATA_VOTO_MVP_ORE,
|
||||
type VotoMvpPubblico,
|
||||
} from "@/lib/mvp-voti";
|
||||
|
||||
/**
|
||||
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
|
||||
* Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
|
||||
* testa gli MVP sono più d'uno.
|
||||
*
|
||||
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
|
||||
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
|
||||
@@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiMvp();
|
||||
const miei = useMieiVotiMvp(io?.id);
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
const tutti: VotoMvpPubblico[] = voti.data ?? [];
|
||||
const conteggio = conteggioPartita(tutti, matchId);
|
||||
const mio = io ? mioVoto(tutti, matchId, io.id) : null;
|
||||
const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
|
||||
const chiuso = votoMvpChiuso(evento.data, evento.ora);
|
||||
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
|
||||
const testa = conteggio[0];
|
||||
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
|
||||
const puoVotare = !!io && presente(io.id);
|
||||
const mvp = mvpPartita(tutti, matchId);
|
||||
const puoVotare = !!io && !mio && !chiuso && presente(io.id);
|
||||
|
||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||
return (
|
||||
@@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
votato_nome: nome,
|
||||
});
|
||||
setAperto(false);
|
||||
setScelto(null);
|
||||
toast.success(`Voto MVP registrato: ${nome}`);
|
||||
} catch {
|
||||
toast.error("Voto non riuscito, riprova");
|
||||
toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,58 +87,87 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
<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"}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{mio ? "Cambia voto" : "Vota"}
|
||||
</button>
|
||||
{mio || chiuso ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
Vota
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[13px]">
|
||||
{testa && !pareggio ? (
|
||||
<span className="inline-flex items-center gap-1 font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
|
||||
{mvp.length > 0 ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
|
||||
{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 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>
|
||||
)}
|
||||
</p>
|
||||
{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 ? (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Vota chi era presente a questa partita.
|
||||
</p>
|
||||
) : null}
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{aperto ? (
|
||||
<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
|
||||
{aperto && !mio ? (
|
||||
<>
|
||||
<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
|
||||
anche dal vincolo `mvp_no_autovoto` (M12). */}
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
scelto?.id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</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 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{conteggio.map((c) => (
|
||||
|
||||
@@ -310,11 +310,14 @@ export function SezioneTendina({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperta((v) => !v)}
|
||||
className="mb-3 flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]",
|
||||
(aperta || anteprima) && "mb-3",
|
||||
)}
|
||||
aria-expanded={aperta}
|
||||
aria-controls={id}
|
||||
>
|
||||
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
|
||||
<h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{azione}
|
||||
<ChevronDown
|
||||
|
||||
@@ -536,6 +536,7 @@ export type Database = {
|
||||
parziali: Json;
|
||||
set_loro: number;
|
||||
set_nostri: number;
|
||||
valutazioni: Json;
|
||||
};
|
||||
Insert: {
|
||||
avversario: string;
|
||||
@@ -548,6 +549,7 @@ export type Database = {
|
||||
parziali?: Json;
|
||||
set_loro: number;
|
||||
set_nostri: number;
|
||||
valutazioni?: Json;
|
||||
};
|
||||
Update: {
|
||||
avversario?: string;
|
||||
@@ -560,6 +562,7 @@ export type Database = {
|
||||
parziali?: Json;
|
||||
set_loro?: number;
|
||||
set_nostri?: number;
|
||||
valutazioni?: Json;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
@@ -625,7 +628,14 @@ export type Database = {
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
mvp_voti_anonimi: {
|
||||
Row: {
|
||||
match_id: string | null;
|
||||
votato_id: string | null;
|
||||
votato_nome: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
};
|
||||
Functions: {
|
||||
has_role: {
|
||||
|
||||
+79
-47
@@ -8,52 +8,91 @@ export type VotoMvp = {
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */
|
||||
export type VotoMvpPubblico = Pick<VotoMvp, "match_id" | "votato_id" | "votato_nome">;
|
||||
|
||||
const CHIAVE = ["mvp-voti"] as const;
|
||||
const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const;
|
||||
|
||||
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||
export const ORE_ATTESA_MVP = 2;
|
||||
|
||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
|
||||
/** Per quante ore resta aperto il voto, dall'apertura (DD-052). */
|
||||
export const DURATA_VOTO_MVP_ORE = 24;
|
||||
|
||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita. */
|
||||
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
|
||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||
if (Number.isNaN(inizio.getTime())) return false;
|
||||
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
|
||||
}
|
||||
|
||||
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
|
||||
/** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */
|
||||
export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean {
|
||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||
if (Number.isNaN(inizio.getTime())) return false;
|
||||
const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE;
|
||||
return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000;
|
||||
}
|
||||
|
||||
/** Si può votare adesso: aperto e non ancora chiuso. */
|
||||
export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean {
|
||||
return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso);
|
||||
}
|
||||
|
||||
/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto).
|
||||
* Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052).
|
||||
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
||||
export function useVotiMvp() {
|
||||
return useQuery({
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<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[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.select("match_id, votante_id, votato_id, votato_nome");
|
||||
.select("match_id, votante_id, votato_id, votato_nome")
|
||||
.eq("votante_id", votanteId!);
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoMvp[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
|
||||
export function useVotaMvp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoMvp) => {
|
||||
const { error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
||||
const { error } = await supabase.from("mvp_voti").insert(voto);
|
||||
if (error) throw error;
|
||||
return voto;
|
||||
},
|
||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||
onSuccess: (voto) => {
|
||||
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => {
|
||||
const altri = (prec ?? []).filter(
|
||||
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id),
|
||||
);
|
||||
return [...altri, voto];
|
||||
});
|
||||
qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
|
||||
...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
|
||||
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;
|
||||
|
||||
/** 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>();
|
||||
for (const v of voti) {
|
||||
if (v.match_id !== matchId) continue;
|
||||
@@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp
|
||||
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
||||
}
|
||||
|
||||
/** Vincitore per ogni partita votata: matchId -> nome MVP. */
|
||||
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
||||
const perMatch = new Map<string, VotoMvp[]>();
|
||||
for (const v of voti) {
|
||||
const arr = perMatch.get(v.match_id) ?? [];
|
||||
arr.push(v);
|
||||
perMatch.set(v.match_id, arr);
|
||||
}
|
||||
/**
|
||||
* MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
|
||||
* merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
|
||||
*/
|
||||
export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
if (totaleVoti < VOTI_MINIMI_MVP) return [];
|
||||
return top.filter((c) => c.voti === top[0]!.voti);
|
||||
}
|
||||
|
||||
/** «Anna», «Anna e Bruno», «Anna, Bruno e Carla». */
|
||||
export function elencoNomi(nomi: string[]): string {
|
||||
if (nomi.length <= 1) return nomi.join("");
|
||||
return `${nomi.slice(0, -1).join(", ")} e ${nomi[nomi.length - 1]}`;
|
||||
}
|
||||
|
||||
/** MVP per ogni partita votata: matchId -> nome (più nomi uniti se c'è una parità in testa). */
|
||||
export function vincitoriMvp(voti: VotoMvpPubblico[]): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [matchId] of perMatch) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
out[matchId] = top[0]!.nome;
|
||||
}
|
||||
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||
const mvp = mvpPartita(voti, matchId);
|
||||
if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
|
||||
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
|
||||
* MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
|
||||
* parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
|
||||
* restituisce una mappa vuota.
|
||||
*/
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
||||
for (const matchId of matchIds) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
const id = top[0]!.id;
|
||||
out[id] = (out[id] ?? 0) + 1;
|
||||
}
|
||||
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||
for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+2
-1
@@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string {
|
||||
export function useAnagraficaRosa({
|
||||
conAllenatori = false,
|
||||
}: { conAllenatori?: boolean } = {}): Array<
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
|
||||
> {
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
return useMemo(
|
||||
@@ -44,6 +44,7 @@ export function useAnagraficaRosa({
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
cognome: g.cognome,
|
||||
ruolo: g.ruolo,
|
||||
numero: g.numero,
|
||||
nascita: g.nascita ?? "",
|
||||
|
||||
+33
-1
@@ -1,5 +1,11 @@
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
import {
|
||||
azioniMeta,
|
||||
contaValutazioni,
|
||||
fondamentali,
|
||||
totaliPerGiocatore,
|
||||
type ScoutMatch,
|
||||
} from "./scout-store";
|
||||
|
||||
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
|
||||
export function rigaCsv(campi: Array<string | number>) {
|
||||
@@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
|
||||
if (!t) continue;
|
||||
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
}
|
||||
if (match.valutazioni.length > 0) {
|
||||
righe.push("");
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
"Set",
|
||||
"Numero",
|
||||
"Giocatore",
|
||||
...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]),
|
||||
]),
|
||||
);
|
||||
const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y);
|
||||
for (const set of sets) {
|
||||
for (const g of rosa) {
|
||||
if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue;
|
||||
const c = contaValutazioni(match.valutazioni, g.id, set);
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
set,
|
||||
g.numero,
|
||||
g.nome,
|
||||
...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
righe.push("");
|
||||
righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
|
||||
for (const a of match.azioni) {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Azione } from "./scout-store";
|
||||
import type { Azione, Valutazione } from "./scout-store";
|
||||
|
||||
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
||||
export type StatoScout = {
|
||||
azioni: Azione[];
|
||||
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
|
||||
valutazioni?: Valutazione[];
|
||||
setChiusi: Array<[number, number]>;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
@@ -12,6 +14,7 @@ export type StatoScout = {
|
||||
|
||||
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
|
||||
azioni: [],
|
||||
valutazioni: [],
|
||||
setChiusi: [],
|
||||
avversario,
|
||||
casa,
|
||||
|
||||
+98
-1
@@ -60,6 +60,90 @@ export type Azione = {
|
||||
ts: number;
|
||||
};
|
||||
|
||||
/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */
|
||||
export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro";
|
||||
|
||||
export const fondamentali: Array<{ id: Fondamentale; label: string }> = [
|
||||
{ id: "battuta", label: "Battuta" },
|
||||
{ id: "ricezione", label: "Ricezione" },
|
||||
{ id: "attacco", label: "Attacco" },
|
||||
{ id: "muro", label: "Muro" },
|
||||
];
|
||||
|
||||
export type Valutazione = {
|
||||
id: string;
|
||||
giocatoreId: string;
|
||||
fondamentale: Fondamentale;
|
||||
segno: "+" | "-";
|
||||
set: number;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type ConteggioValutazioni = Record<Fondamentale, { piu: number; meno: number }>;
|
||||
|
||||
/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */
|
||||
export function contaValutazioni(
|
||||
valutazioni: Valutazione[],
|
||||
giocatoreId: string,
|
||||
set?: number,
|
||||
): ConteggioValutazioni {
|
||||
const out = {} as ConteggioValutazioni;
|
||||
for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 };
|
||||
for (const v of valutazioni) {
|
||||
if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue;
|
||||
out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riepilogo delle valutazioni per la lettura nell'app: totali di squadra e per giocatore,
|
||||
* su tutta la partita o su un solo set. Chi non ha valutazioni nel periodo non compare.
|
||||
*/
|
||||
export function sintesiValutazioni(valutazioni: Valutazione[], set?: number) {
|
||||
const filtrate = set === undefined ? valutazioni : valutazioni.filter((v) => v.set === set);
|
||||
const squadra = contaValutazioni([], "", undefined);
|
||||
const perGiocatore = [...new Set(filtrate.map((v) => v.giocatoreId))].map((giocatoreId) => {
|
||||
const c = contaValutazioni(filtrate, giocatoreId);
|
||||
for (const f of fondamentali) {
|
||||
squadra[f.id].piu += c[f.id].piu;
|
||||
squadra[f.id].meno += c[f.id].meno;
|
||||
}
|
||||
return { giocatoreId, conteggi: c };
|
||||
});
|
||||
return { squadra, perGiocatore };
|
||||
}
|
||||
|
||||
/**
|
||||
* I + di un giocatore in un set, esclusa la ricezione (che qui non vale come punto messo a
|
||||
* terra): quello che compare nel suo riquadro dello scout.
|
||||
*/
|
||||
export function contaPiuSenzaRicezione(
|
||||
valutazioni: Valutazione[],
|
||||
giocatoreId: string,
|
||||
set: number,
|
||||
): number {
|
||||
return valutazioni.filter(
|
||||
(v) =>
|
||||
v.giocatoreId === giocatoreId &&
|
||||
v.set === set &&
|
||||
v.segno === "+" &&
|
||||
v.fondamentale !== "ricezione",
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parziali da archiviare: i set chiusi più il set in corso, se ha qualcosa dentro (punti o
|
||||
* valutazioni). Senza punteggio un set vale 0-0: serve solo a numerarlo.
|
||||
*/
|
||||
export function parzialiFinali(
|
||||
setChiusi: Array<[number, number]>,
|
||||
corrente: [number, number],
|
||||
haDati: boolean,
|
||||
): Array<[number, number]> {
|
||||
return haDati ? [...setChiusi, corrente] : setChiusi;
|
||||
}
|
||||
|
||||
export type ScoutMatch = {
|
||||
id: string;
|
||||
data: string;
|
||||
@@ -69,6 +153,7 @@ export type ScoutMatch = {
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
azioni: Azione[];
|
||||
valutazioni: Valutazione[];
|
||||
};
|
||||
|
||||
type RigaScoutPartita = {
|
||||
@@ -80,6 +165,7 @@ type RigaScoutPartita = {
|
||||
set_loro: number;
|
||||
parziali: unknown;
|
||||
azioni: unknown;
|
||||
valutazioni: unknown;
|
||||
};
|
||||
|
||||
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
@@ -92,6 +178,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
setLoro: r.set_loro,
|
||||
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
|
||||
azioni: (r.azioni as Azione[] | null) ?? [],
|
||||
valutazioni: (r.valutazioni as Valutazione[] | null) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +187,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_partite")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni")
|
||||
.order("creato_il", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(daRiga);
|
||||
@@ -131,6 +218,7 @@ export function useSalvaScoutMatch() {
|
||||
set_loro: input.match.setLoro,
|
||||
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
||||
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
||||
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
|
||||
});
|
||||
if (error) throw error;
|
||||
return input.match;
|
||||
@@ -159,6 +247,15 @@ export function useEliminaScoutMatch() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Una partita scoutata ha un risultato solo se ha registrato punti: quelle fatte di sole
|
||||
* valutazioni − / + hanno parziali 0-0 e non devono sostituire il referto ufficiale né
|
||||
* comparire come risultato in Home e Classifica.
|
||||
*/
|
||||
export function haPunteggio(match: Pick<ScoutMatch, "parziali">): boolean {
|
||||
return match.parziali.some(([n, l]) => n + l > 0);
|
||||
}
|
||||
|
||||
/** Somma delle azioni di un match per giocatore. */
|
||||
export function totaliPerGiocatore(azioni: Azione[]) {
|
||||
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { formatData, type RigaClassifica } from "@/lib/crapp-data";
|
||||
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import {
|
||||
isNostraSquadra,
|
||||
@@ -150,7 +150,7 @@ function Classifica() {
|
||||
const m = matchDaPartitaCsi(p);
|
||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||
})
|
||||
: scoutMatches.map((m) => ({
|
||||
: scoutMatches.filter(haPunteggio).map((m) => ({
|
||||
id: m.id,
|
||||
data: m.data,
|
||||
avversario: m.avversario,
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
||||
import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
|
||||
|
||||
@@ -63,7 +63,9 @@ function Index() {
|
||||
const votiMvp = useVotiMvp();
|
||||
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
|
||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||
const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null);
|
||||
const ultimaBase = csiGiocate[0]
|
||||
? matchDaPartitaCsi(csiGiocate[0])
|
||||
: (scoutMatches.find(haPunteggio) ?? null);
|
||||
const obiettivi = useObiettivi();
|
||||
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
||||
const eventoUltima = ultimaBase
|
||||
|
||||
+64
-90
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { Card, PageHeader, Section, SezioneTendina } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
@@ -10,9 +10,10 @@ import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
import { sondaggioAperto } from "@/lib/cacche";
|
||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { ScoutReport } from "@/components/crapp/ScoutReport";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
@@ -70,26 +71,26 @@ function PartitaDetail() {
|
||||
const csiMatch = csi
|
||||
? partiteGiocate(csi.partite).find((p) => p.data === evento.data)
|
||||
: undefined;
|
||||
const match = scout
|
||||
? {
|
||||
id: scout.id,
|
||||
data: scout.data,
|
||||
avversario: scout.avversario,
|
||||
logoAvversario: "",
|
||||
casa: scout.casa,
|
||||
setNostri: scout.setNostri,
|
||||
setLoro: scout.setLoro,
|
||||
parziali: scout.parziali,
|
||||
campo: "",
|
||||
girone: "",
|
||||
numeroGara: "",
|
||||
arbitro: "",
|
||||
link: "",
|
||||
}
|
||||
: csiMatch
|
||||
? matchDaPartitaCsi(csiMatch)
|
||||
: undefined;
|
||||
const totaliTeam = scout ? totaliSquadra([scout]) : null;
|
||||
const match =
|
||||
scout && haPunteggio(scout)
|
||||
? {
|
||||
id: scout.id,
|
||||
data: scout.data,
|
||||
avversario: scout.avversario,
|
||||
logoAvversario: "",
|
||||
casa: scout.casa,
|
||||
setNostri: scout.setNostri,
|
||||
setLoro: scout.setLoro,
|
||||
parziali: scout.parziali,
|
||||
campo: "",
|
||||
girone: "",
|
||||
numeroGara: "",
|
||||
arbitro: "",
|
||||
link: "",
|
||||
}
|
||||
: csiMatch
|
||||
? matchDaPartitaCsi(csiMatch)
|
||||
: undefined;
|
||||
const avversario = evento.titolo.includes(" vs ")
|
||||
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
|
||||
: evento.titolo;
|
||||
@@ -205,9 +206,16 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
{csiMatch ? (
|
||||
<Section titolo="Formazioni e scontri diretti">
|
||||
<SezioneTendina
|
||||
titolo="Formazioni e scontri diretti"
|
||||
azione={
|
||||
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
||||
Livescore CSI
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
<Section titolo="MVP eletto dalla squadra">
|
||||
@@ -219,9 +227,9 @@ function PartitaDetail() {
|
||||
{match ? (
|
||||
// Badge e cacche non si mostrano all'allenatore (DD-034).
|
||||
sonoAllenatore ? null : (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<SezioneTendina titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
)
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
@@ -237,83 +245,49 @@ function PartitaDetail() {
|
||||
</Section>
|
||||
|
||||
{sonoAllenatore ? null : (
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SezioneTendina
|
||||
titolo="Sondaggio pre-partita"
|
||||
defaultAperta={sondaggioAperto(evento.data, evento.ora)}
|
||||
>
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
)}
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Pagelle di fine partita">
|
||||
<Pagelle matchId={match.id} convocati={convocati} chiuse={evento.pagelleChiuse} />
|
||||
</Section>
|
||||
<SezioneTendina titolo="Pagelle di fine partita">
|
||||
<Pagelle
|
||||
matchId={match.id}
|
||||
convocati={convocati.filter(
|
||||
(g) => risposte[g.id] !== "assente" && risposte[g.id] !== "infortunato",
|
||||
)}
|
||||
chiuse={evento.pagelleChiuse}
|
||||
/>
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
{scout && totaliTeam ? (
|
||||
<Section titolo="Report tecnico">
|
||||
<Card>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ l: "Punti", v: totaliTeam.punti },
|
||||
{ l: "Ace", v: totaliTeam.ace },
|
||||
{ l: "Muri", v: totaliTeam.muri },
|
||||
{ l: "Errori", v: totaliTeam.errori },
|
||||
].map((t) => (
|
||||
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
{t.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Dettaglio giocatori (uso interno allenatori)
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{[...totaliPerGiocatore(scout.azioni).entries()].map(([gid, t]) => {
|
||||
const g = rosa.find((x) => x.id === gid);
|
||||
if (!g) return null;
|
||||
return (
|
||||
<div
|
||||
key={gid}
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||
#{g.numero} {g.nome}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(
|
||||
`scout-${scout.data}-${scout.avversario}.csv`,
|
||||
csvScoutMatch(scout, rosa),
|
||||
)
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
</button>
|
||||
) : null}
|
||||
</Card>
|
||||
</Section>
|
||||
{scout ? (
|
||||
<SezioneTendina
|
||||
titolo="Scout"
|
||||
azione={
|
||||
scout.valutazioni.length > 0 ? (
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
{scout.valutazioni.length} valutazioni
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ScoutReport scout={scout} rosa={rosa} admin={admin} />
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<SezioneTendina titolo="Rosa e presenze" defaultAperta>
|
||||
<RosaPresenze
|
||||
eventoId={evento.id}
|
||||
data={evento.data}
|
||||
tipo={evento.tipo}
|
||||
convocati={evento.convocati}
|
||||
/>
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+132
-166
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Select } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
@@ -27,10 +27,15 @@ import {
|
||||
} from "@/lib/scout-live";
|
||||
import {
|
||||
azioniMeta,
|
||||
contaPiuSenzaRicezione,
|
||||
contaValutazioni,
|
||||
parzialiFinali,
|
||||
fondamentali,
|
||||
useSalvaScoutMatch,
|
||||
totaliPerGiocatore,
|
||||
type Azione,
|
||||
type AzioneTipo,
|
||||
type Fondamentale,
|
||||
type Valutazione,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
export const Route = createFileRoute("/scout")({
|
||||
@@ -52,8 +57,6 @@ export const Route = createFileRoute("/scout")({
|
||||
component: Scout,
|
||||
});
|
||||
|
||||
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
|
||||
|
||||
function Blocco({
|
||||
icona,
|
||||
titolo,
|
||||
@@ -119,7 +122,11 @@ function Scout() {
|
||||
|
||||
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
|
||||
const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0;
|
||||
const ripresa =
|
||||
!!statoSalvato.data &&
|
||||
(statoSalvato.data.azioni.length > 0 ||
|
||||
(statoSalvato.data.valutazioni?.length ?? 0) > 0 ||
|
||||
statoSalvato.data.setChiusi.length > 0);
|
||||
|
||||
if (!controllo) {
|
||||
return (
|
||||
@@ -196,10 +203,12 @@ function ScoutBoard({
|
||||
.trim() || "Avversario",
|
||||
partita.titolo.trim().toLowerCase().startsWith("crap"),
|
||||
);
|
||||
const [avversario, setAvversario] = useState(base.avversario);
|
||||
const [casa, setCasa] = useState(base.casa);
|
||||
const [avversario] = useState(base.avversario);
|
||||
const [casa] = useState(base.casa);
|
||||
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
|
||||
const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
|
||||
const [valutazioni, setValutazioni] = useState<Valutazione[]>(base.valutazioni ?? []);
|
||||
const [ordine, setOrdine] = useState<"cognome" | "numero">("cognome");
|
||||
const [selezionato, setSelezionato] = useState<string | null>(null);
|
||||
const salva = useSalvaStatoScout();
|
||||
const cancella = useCancellaStatoScout();
|
||||
@@ -208,52 +217,73 @@ function ScoutBoard({
|
||||
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
|
||||
const rosa = useAnagraficaRosa();
|
||||
const finito = useRef(false);
|
||||
const codaSalvataggio = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
|
||||
const convocati = useMemo(() => {
|
||||
const presenti = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
);
|
||||
return presenti.length > 0 ? presenti : rosa;
|
||||
}, [risposte, rosa]);
|
||||
const elenco = presenti.length > 0 ? presenti : rosa;
|
||||
return [...elenco].sort((a, b) =>
|
||||
ordine === "numero"
|
||||
? a.numero - b.numero
|
||||
: a.cognome.localeCompare(b.cognome, "it") || a.numero - b.numero,
|
||||
);
|
||||
}, [risposte, rosa, ordine]);
|
||||
|
||||
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
|
||||
useEffect(() => {
|
||||
if (finito.current) return;
|
||||
const id = window.setTimeout(() => {
|
||||
void salva.mutateAsync({
|
||||
eventoId: partita.id,
|
||||
stato: { azioni, setChiusi, avversario, casa },
|
||||
// Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si
|
||||
// chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito
|
||||
// rimette a posto tutto; la coda li tiene in ordine.
|
||||
const stato = { azioni, valutazioni, setChiusi, avversario, casa };
|
||||
codaSalvataggio.current = codaSalvataggio.current
|
||||
.then(() => salva.mutateAsync({ eventoId: partita.id, stato }))
|
||||
.then(() => {
|
||||
toast.dismiss("scout-salvataggio");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Salvataggio non riuscito: controlla la connessione", {
|
||||
id: "scout-salvataggio",
|
||||
duration: Infinity,
|
||||
});
|
||||
});
|
||||
}, 800);
|
||||
return () => window.clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [azioni, setChiusi, avversario, casa, partita.id]);
|
||||
}, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]);
|
||||
|
||||
const setCorrente = setChiusi.length + 1;
|
||||
const azioniSet = azioni.filter((a) => a.set === setCorrente);
|
||||
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
|
||||
const puntiLoro = azioniSet.length - puntiNoi;
|
||||
const setNostri = setChiusi.filter(([n, l]) => n > l).length;
|
||||
const setLoro = setChiusi.length - setNostri;
|
||||
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
|
||||
const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
|
||||
|
||||
function registra(tipo: AzioneTipo) {
|
||||
const meta = azioniMeta[tipo];
|
||||
if (meta.richiedeGiocatore && !selezionato) {
|
||||
function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
|
||||
if (!selezionato) {
|
||||
toast.error("Seleziona prima il giocatore");
|
||||
return;
|
||||
}
|
||||
setAzioni((prev) => {
|
||||
const nuova: Azione = {
|
||||
setValutazioni((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `${Date.now()}-${prev.length}`,
|
||||
tipo,
|
||||
giocatoreId: selezionato,
|
||||
fondamentale,
|
||||
segno,
|
||||
set: setCorrente,
|
||||
ts: Date.now(),
|
||||
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}),
|
||||
};
|
||||
return [...prev, nuova];
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function annullaValutazione() {
|
||||
const ultima = valutazioni[valutazioni.length - 1];
|
||||
if (!ultima || ultima.set !== setCorrente) {
|
||||
toast.error("Puoi annullare solo le valutazioni del set in corso");
|
||||
return;
|
||||
}
|
||||
setValutazioni((prev) => prev.slice(0, -1));
|
||||
}
|
||||
|
||||
function annulla() {
|
||||
@@ -266,17 +296,16 @@ function ScoutBoard({
|
||||
}
|
||||
|
||||
function chiudiSet() {
|
||||
if (puntiNoi === 0 && puntiLoro === 0) {
|
||||
toast.error("Set senza azioni");
|
||||
if (!setHaDati) {
|
||||
toast.error("Set senza valutazioni");
|
||||
return;
|
||||
}
|
||||
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
|
||||
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
|
||||
toast.success(`Set ${setCorrente} chiuso`);
|
||||
}
|
||||
|
||||
async function finePartita() {
|
||||
const parziali: Array<[number, number]> =
|
||||
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
|
||||
const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
|
||||
if (parziali.length === 0) {
|
||||
toast.error("Nessun set da salvare");
|
||||
return;
|
||||
@@ -294,21 +323,24 @@ function ScoutBoard({
|
||||
setLoro: parziali.length - vinti,
|
||||
parziali,
|
||||
azioni,
|
||||
valutazioni,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error("Salvataggio non riuscito, riprova");
|
||||
return;
|
||||
}
|
||||
toast.success("Partita salvata: ora la squadra può votare l'MVP");
|
||||
toast.success("Partita salvata");
|
||||
finito.current = true;
|
||||
void cancella.mutateAsync(partita.id);
|
||||
onFine();
|
||||
navigate({ to: "/squadra" });
|
||||
}
|
||||
|
||||
const ultime = [...azioni].slice(-4).reverse();
|
||||
|
||||
const giocatoreSel = rosa.find((g) => g.id === selezionato);
|
||||
const contiSelezionato = selezionato
|
||||
? contaValutazioni(valutazioni, selezionato, setCorrente)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
|
||||
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
|
||||
</div>
|
||||
<p className="font-display text-2xl leading-none text-primary-foreground/50">
|
||||
{setNostri}-{setLoro}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
|
||||
{avversario || "Avversario"}
|
||||
</p>
|
||||
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setChiusi.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
|
||||
{setChiusi.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[13px] font-semibold tabular-nums"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
1. Tocca il giocatore
|
||||
</p>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Selezione giocatore
|
||||
</p>
|
||||
<div className="w-40 shrink-0">
|
||||
<Select
|
||||
value={ordine}
|
||||
onChange={(e) => setOrdine(e.target.value as "cognome" | "numero")}
|
||||
aria-label="Ordina i giocatori"
|
||||
>
|
||||
<option value="cognome">Per cognome</option>
|
||||
<option value="numero">Per numero</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{convocati.map((g) => {
|
||||
const attivo = selezionato === g.id;
|
||||
const t = totali.get(g.id);
|
||||
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
@@ -391,7 +406,7 @@ function ScoutBoard({
|
||||
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t ? `${t.punti}p` : "0p"}
|
||||
+{piu}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
@@ -400,105 +415,56 @@ function ScoutBoard({
|
||||
</section>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
2. Tocca l'azione
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ordineAzioni.map((tipo) => (
|
||||
<button
|
||||
key={tipo}
|
||||
type="button"
|
||||
onClick={() => registra(tipo)}
|
||||
className={cn(
|
||||
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
|
||||
azioniMeta[tipo].className,
|
||||
!selezionato && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{azioniMeta[tipo].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Ultime azioni
|
||||
Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
|
||||
{setCorrente}
|
||||
</p>
|
||||
{ultime.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{ultime.map((a, i) => {
|
||||
const g = rosa.find((x) => x.id === a.giocatoreId);
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-1 text-[13px]",
|
||||
i === 0 && "anim-riga",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[13px] font-bold uppercase",
|
||||
azioniMeta[a.tipo].className,
|
||||
)}
|
||||
>
|
||||
{azioniMeta[a.tipo].short}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={annullaValutazione}
|
||||
disabled={!valutazioni.some((v) => v.set === setCorrente)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 rounded-3xl bg-card p-3 shadow-card",
|
||||
!selezionato && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{!selezionato ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Tocca un giocatore: i + e i − non cambiano il punteggio.
|
||||
</p>
|
||||
) : null}
|
||||
{fondamentali.map((f) => {
|
||||
const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 };
|
||||
return (
|
||||
<div key={f.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-2">
|
||||
<span className="text-[15px] font-bold">{f.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${f.label}: meno`}
|
||||
onClick={() => valuta(f.id, "-")}
|
||||
className="min-w-16 rounded-2xl bg-destructive px-4 py-2.5 font-display text-xl leading-none text-destructive-foreground shadow-card transition-transform active:scale-95"
|
||||
>
|
||||
− <span className="tabular-nums">{c.meno}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${f.label}: più`}
|
||||
onClick={() => valuta(f.id, "+")}
|
||||
className="min-w-16 rounded-2xl bg-success px-4 py-2.5 font-display text-xl leading-none text-success-foreground shadow-card transition-transform active:scale-95"
|
||||
>
|
||||
+ <span className="tabular-nums">{c.piu}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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;
|
||||
@@ -62,10 +62,8 @@ try {
|
||||
const csi = (await json(res)) as DatiCsi;
|
||||
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||
assert.ok(noi, "la nostra squadra arriva dal portale");
|
||||
assert.ok(
|
||||
csi.partite.some((p) => p.setNostri !== null),
|
||||
"ci sono risultati giocati",
|
||||
);
|
||||
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata.
|
||||
assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale");
|
||||
|
||||
const html = await pagina("/classifica");
|
||||
assert.match(titolo(html), /Classifica campionato/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -124,9 +124,13 @@ try {
|
||||
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
|
||||
});
|
||||
|
||||
await provaCsi(
|
||||
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
|
||||
async () => {
|
||||
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test
|
||||
// del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile.
|
||||
const nomeDettaglio = "GET /api/public/csi-partita/$id restituisce formazioni e precedenti";
|
||||
if (csiRaggiungibile && !partiteGiocate(csi?.partite ?? [])[0]) {
|
||||
salta(nomeDettaglio, "nessuna gara ancora giocata nella stagione");
|
||||
} else
|
||||
await provaCsi(nomeDettaglio, async () => {
|
||||
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
|
||||
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
|
||||
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||
@@ -147,8 +151,7 @@ try {
|
||||
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||
assert.equal(secondo.status, 200);
|
||||
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await provaCsi(
|
||||
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
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 { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
@@ -52,11 +52,11 @@ if (!locale) {
|
||||
}
|
||||
|
||||
/** Rilegge esattamente come `useVotiMvp()`. */
|
||||
async function leggiVoti(): Promise<VotoMvp[]> {
|
||||
async function leggiVoti(): Promise<VotoMvpPubblico[]> {
|
||||
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`. */
|
||||
@@ -68,8 +68,8 @@ if (!locale) {
|
||||
await prova(
|
||||
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
|
||||
async () => {
|
||||
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
|
||||
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
|
||||
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
|
||||
// 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.
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m1`,
|
||||
@@ -107,17 +107,15 @@ if (!locale) {
|
||||
votato_id: "vg1",
|
||||
votato_nome: "Uno",
|
||||
});
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m3`,
|
||||
votante_id: "vb",
|
||||
votato_id: "vg2",
|
||||
votato_nome: "Due",
|
||||
});
|
||||
|
||||
const voti = await leggiVoti();
|
||||
const vinti = mvpVintiPerGiocatore(voti);
|
||||
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
|
||||
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
|
||||
assert.equal(
|
||||
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));
|
||||
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 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`.
|
||||
*/
|
||||
import { romaTra } from "../helpers/roma";
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
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.
|
||||
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||
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, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
@@ -310,12 +311,47 @@ if (!locale) {
|
||||
}),
|
||||
});
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
match_id: EVENTO_MVP,
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
@@ -323,11 +359,55 @@ if (!locale) {
|
||||
});
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
match_id: EVENTO_MVP,
|
||||
votante_id: "g5",
|
||||
votato_id: "g1",
|
||||
votato_nome: "Uno",
|
||||
@@ -336,6 +416,265 @@ if (!locale) {
|
||||
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 () => {
|
||||
const mie = await rest("cacche_partita", tokenGiocatore, {
|
||||
method: "POST",
|
||||
@@ -455,8 +794,8 @@ if (!locale) {
|
||||
id: EVENTO_CON_G1,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con g1 convocato, g2 no",
|
||||
data: "2026-01-02",
|
||||
ora: "20:00",
|
||||
data: romaTra(-3).data,
|
||||
ora: romaTra(-3).ora,
|
||||
luogo: "Palestra",
|
||||
convocati: ["g1", "g5"],
|
||||
}),
|
||||
@@ -482,9 +821,17 @@ if (!locale) {
|
||||
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
||||
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
||||
// (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, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CON_G1,
|
||||
votante_id: "g1",
|
||||
@@ -503,8 +850,8 @@ if (!locale) {
|
||||
id: EVENTO_CHIUSO,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con pagelle chiuse",
|
||||
data: "2026-01-03",
|
||||
ora: "20:00",
|
||||
data: romaTra(-3).data,
|
||||
ora: romaTra(-3).ora,
|
||||
luogo: "Palestra",
|
||||
pagelle_chiuse: true,
|
||||
}),
|
||||
@@ -529,9 +876,17 @@ if (!locale) {
|
||||
// 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
|
||||
// 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, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CHIUSO,
|
||||
votante_id: "g1",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
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 { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
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()}`);
|
||||
}
|
||||
|
||||
async function leggiMvp(): Promise<VotoMvp[]> {
|
||||
async function leggiMvp(): Promise<VotoMvpPubblico[]> {
|
||||
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[]> {
|
||||
|
||||
@@ -32,6 +32,7 @@ if (!locale) {
|
||||
"turni_palloni",
|
||||
"risposte_presenze",
|
||||
"scout_live",
|
||||
"scout_partite",
|
||||
] as const;
|
||||
|
||||
const rest = (percorso: string, init?: RequestInit) =>
|
||||
@@ -312,6 +313,32 @@ if (!locale) {
|
||||
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
|
||||
);
|
||||
});
|
||||
|
||||
// --- scout: le valutazioni − / + stanno accanto alle azioni (m39) ----------------
|
||||
await prova(
|
||||
"la partita scoutata conserva le valutazioni; senza, l'elenco è vuoto",
|
||||
async () => {
|
||||
const evento = `${PREFISSO}-e5`;
|
||||
const base = {
|
||||
evento_id: evento,
|
||||
data: "2026-11-02",
|
||||
avversario: "X",
|
||||
set_nostri: 3,
|
||||
set_loro: 0,
|
||||
};
|
||||
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s1` });
|
||||
const valutazioni = [
|
||||
{ id: "v1", giocatoreId: "g1", fondamentale: "battuta", segno: "+", set: 1, ts: 0 },
|
||||
];
|
||||
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s2`, valutazioni });
|
||||
const righe = await leggi(
|
||||
"scout_partite",
|
||||
`evento_id=eq.${evento}&select=id,valutazioni&order=id`,
|
||||
);
|
||||
assert.deepEqual(righe[0]?.["valutazioni"], [], "le partite senza valutazioni hanno []");
|
||||
assert.deepEqual(righe[1]?.["valutazioni"], valutazioni);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
for (const tabella of TABELLE) {
|
||||
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
conteggioPartita,
|
||||
elencoNomi,
|
||||
mioVoto,
|
||||
mvpPartita,
|
||||
mvpVintiPerGiocatore,
|
||||
DURATA_VOTO_MVP_ORE,
|
||||
ORE_ATTESA_MVP,
|
||||
vincitoriMvp,
|
||||
votoMvpAperto,
|
||||
votoMvpChiuso,
|
||||
votoMvpInCorso,
|
||||
VOTI_MINIMI_MVP,
|
||||
type VotoMvp,
|
||||
} from "@/lib/mvp-voti";
|
||||
@@ -44,9 +51,13 @@ assert.deepEqual(
|
||||
["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(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([]), {});
|
||||
|
||||
// --- vincitoriMvp: quorum minimo di voti -------------------------------------
|
||||
@@ -69,13 +80,38 @@ const triplaPari = [
|
||||
v("m6", "g2", "g8", "Anna"),
|
||||
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(
|
||||
mvpVintiPerGiocatore(triplaPari),
|
||||
{},
|
||||
"stessa parità: nessuna vittoria netta da contare",
|
||||
{ g9: 1, g8: 1, g7: 1 },
|
||||
"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 -----------------------------------------------------------------
|
||||
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
|
||||
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
|
||||
@@ -89,8 +125,8 @@ assert.deepEqual(
|
||||
);
|
||||
assert.deepEqual(
|
||||
mvpVintiPerGiocatore([...partita, ...pari]),
|
||||
{ g2: 1, g5: 1 },
|
||||
"la parità non assegna",
|
||||
{ g2: 1, g5: 1, g9: 1, g8: 1 },
|
||||
"m3 è pari: i due pari merito prendono un titolo ciascuno",
|
||||
);
|
||||
assert.deepEqual(
|
||||
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(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");
|
||||
@@ -29,6 +29,7 @@ const match: ScoutMatch = {
|
||||
[25, 18],
|
||||
],
|
||||
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
|
||||
valutazioni: [],
|
||||
};
|
||||
|
||||
const csv = csvScoutMatch(match, giocatori);
|
||||
@@ -78,3 +79,42 @@ const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }, giocat
|
||||
assert.ok(conVirgolette.includes('"Team ""X"""'));
|
||||
|
||||
console.log("scout-export: ok");
|
||||
|
||||
// --- valutazioni − / + per set -------------------------------------------------
|
||||
assert.ok(!csv.includes("Battuta +"), "senza valutazioni la sezione non c'è");
|
||||
const val = (
|
||||
giocatoreId: string,
|
||||
fondamentale: "battuta" | "muro",
|
||||
segno: "+" | "-",
|
||||
set: number,
|
||||
) => ({
|
||||
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
|
||||
giocatoreId,
|
||||
fondamentale,
|
||||
segno,
|
||||
set,
|
||||
ts: 0,
|
||||
});
|
||||
const conVal = csvScoutMatch(
|
||||
{
|
||||
...match,
|
||||
valutazioni: [
|
||||
val(g1.id, "battuta", "+", 1),
|
||||
val(g1.id, "battuta", "+", 1),
|
||||
val(g1.id, "battuta", "-", 1),
|
||||
val(g1.id, "muro", "-", 2),
|
||||
],
|
||||
},
|
||||
giocatori,
|
||||
);
|
||||
assert.ok(
|
||||
conVal.includes(
|
||||
"Set;Numero;Giocatore;Battuta +;Battuta -;Ricezione +;Ricezione -;Attacco +;Attacco -;Muro +;Muro -",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
conVal.includes(`1;${g1.numero};${g1.nome};2;1;0;0;0;0;0;0`),
|
||||
"set 1: 2 più e 1 meno in battuta",
|
||||
);
|
||||
assert.ok(conVal.includes(`2;${g1.numero};${g1.nome};0;0;0;0;0;0;0;1`), "set 2: 1 meno a muro");
|
||||
assert.ok(!conVal.includes(`;${g2.nome};0;0;0;0;0;0;0;0`), "chi non ha valutazioni non ha riga");
|
||||
@@ -5,7 +5,10 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
|
||||
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
|
||||
const stato = statoIniziale("Avversari", true);
|
||||
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
|
||||
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
|
||||
assert.deepEqual(
|
||||
{ ...stato },
|
||||
{ azioni: [], valutazioni: [], setChiusi: [], avversario: "Avversari", casa: true },
|
||||
);
|
||||
|
||||
const trasferta = statoIniziale("Altra Squadra", false);
|
||||
assert.equal(trasferta.casa, false);
|
||||
@@ -14,6 +17,7 @@ assert.deepEqual(trasferta.azioni, [], "ogni chiamata parte da una lista vuota i
|
||||
// Le liste non devono essere condivise tra due stati distinti.
|
||||
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
|
||||
assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro");
|
||||
assert.notEqual(stato.valutazioni, trasferta.valutazioni, "anche le valutazioni sono indipendenti");
|
||||
|
||||
// --- SCOUT_STATO_KEY: chiave stabile per React Query --------------------------
|
||||
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
|
||||
|
||||
@@ -3,10 +3,17 @@ import assert from "node:assert/strict";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import {
|
||||
azioniMeta,
|
||||
contaPiuSenzaRicezione,
|
||||
haPunteggio,
|
||||
parzialiFinali,
|
||||
sintesiValutazioni,
|
||||
contaValutazioni,
|
||||
fondamentali,
|
||||
giocatoriConScout,
|
||||
totaliPerGiocatore,
|
||||
totaliSquadra,
|
||||
type Azione,
|
||||
type Valutazione,
|
||||
type ScoutMatch,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
@@ -32,6 +39,7 @@ const match = (
|
||||
setLoro,
|
||||
parziali: [],
|
||||
azioni,
|
||||
valutazioni: [],
|
||||
});
|
||||
|
||||
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
|
||||
@@ -84,3 +92,125 @@ for (const [tipo, meta] of Object.entries(azioniMeta)) {
|
||||
}
|
||||
|
||||
console.log("scout-store: ok");
|
||||
|
||||
// --- valutazioni − / +: per giocatore, fondamentale e set; fuori dal punteggio -------
|
||||
const v = (
|
||||
giocatoreId: string,
|
||||
fondamentale: Valutazione["fondamentale"],
|
||||
segno: Valutazione["segno"],
|
||||
set = 1,
|
||||
): Valutazione => ({
|
||||
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
|
||||
giocatoreId,
|
||||
fondamentale,
|
||||
segno,
|
||||
set,
|
||||
ts: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
fondamentali.map((f) => f.id),
|
||||
["battuta", "ricezione", "attacco", "muro"],
|
||||
);
|
||||
const val = [
|
||||
v("g1", "battuta", "+"),
|
||||
v("g1", "battuta", "+"),
|
||||
v("g1", "battuta", "-"),
|
||||
v("g1", "ricezione", "-", 2),
|
||||
v("g1", "muro", "+", 2),
|
||||
v("g2", "attacco", "+"),
|
||||
];
|
||||
assert.deepEqual(contaValutazioni(val, "g1", 1), {
|
||||
battuta: { piu: 2, meno: 1 },
|
||||
ricezione: { piu: 0, meno: 0 },
|
||||
attacco: { piu: 0, meno: 0 },
|
||||
muro: { piu: 0, meno: 0 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
contaValutazioni(val, "g1", 2).ricezione,
|
||||
{ piu: 0, meno: 1 },
|
||||
"il set 2 è separato",
|
||||
);
|
||||
assert.deepEqual(
|
||||
contaValutazioni(val, "g1").battuta,
|
||||
{ piu: 2, meno: 1 },
|
||||
"senza set: tutta la partita",
|
||||
);
|
||||
assert.deepEqual(contaValutazioni(val, "g1").ricezione, { piu: 0, meno: 1 });
|
||||
assert.deepEqual(
|
||||
contaValutazioni(val, "g3", 1).battuta,
|
||||
{ piu: 0, meno: 0 },
|
||||
"giocatore senza valutazioni",
|
||||
);
|
||||
assert.equal(totaliPerGiocatore([]).size, 0, "le valutazioni non sono azioni: nessun punto");
|
||||
|
||||
// --- + del riquadro giocatore: tutti i fondamentali tranne la ricezione, nel set -------
|
||||
const piuVal = [
|
||||
v("g1", "battuta", "+"),
|
||||
v("g1", "attacco", "+"),
|
||||
v("g1", "muro", "+"),
|
||||
v("g1", "ricezione", "+"),
|
||||
v("g1", "attacco", "-"),
|
||||
v("g1", "battuta", "+", 2),
|
||||
v("g2", "attacco", "+"),
|
||||
];
|
||||
assert.equal(
|
||||
contaPiuSenzaRicezione(piuVal, "g1", 1),
|
||||
3,
|
||||
"niente ricezione, niente −, solo il set 1",
|
||||
);
|
||||
assert.equal(contaPiuSenzaRicezione(piuVal, "g1", 2), 1);
|
||||
assert.equal(contaPiuSenzaRicezione(piuVal, "g3", 1), 0);
|
||||
|
||||
// --- parziali finali: il set in corso conta solo se ha dati ------------------------
|
||||
assert.deepEqual(
|
||||
parzialiFinali([[25, 20]], [0, 0], true),
|
||||
[
|
||||
[25, 20],
|
||||
[0, 0],
|
||||
],
|
||||
"set con sole valutazioni",
|
||||
);
|
||||
assert.deepEqual(
|
||||
parzialiFinali([[25, 20]], [0, 0], false),
|
||||
[[25, 20]],
|
||||
"set vuoto: non si archivia",
|
||||
);
|
||||
assert.deepEqual(parzialiFinali([], [0, 0], false), [], "nessun set da salvare");
|
||||
|
||||
// --- haPunteggio: le partite di sole valutazioni non sono un risultato -------------
|
||||
assert.equal(
|
||||
haPunteggio({
|
||||
parziali: [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(haPunteggio({ parziali: [] }), false);
|
||||
assert.equal(
|
||||
haPunteggio({
|
||||
parziali: [
|
||||
[0, 0],
|
||||
[25, 18],
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// --- sintesi per la lettura nell'app: squadra e giocatori, partita o singolo set -----
|
||||
const tutta = sintesiValutazioni(val);
|
||||
assert.deepEqual(tutta.squadra.battuta, { piu: 2, meno: 1 });
|
||||
assert.deepEqual(tutta.squadra.attacco, { piu: 1, meno: 0 });
|
||||
assert.deepEqual(tutta.squadra.ricezione, { piu: 0, meno: 1 });
|
||||
assert.deepEqual(tutta.perGiocatore.map((r) => r.giocatoreId).sort(), ["g1", "g2"]);
|
||||
const set2 = sintesiValutazioni(val, 2);
|
||||
assert.deepEqual(
|
||||
set2.perGiocatore.map((r) => r.giocatoreId),
|
||||
["g1"],
|
||||
);
|
||||
assert.deepEqual(set2.squadra.muro, { piu: 1, meno: 0 });
|
||||
assert.deepEqual(set2.squadra.battuta, { piu: 0, meno: 0 });
|
||||
assert.equal(sintesiValutazioni([]).perGiocatore.length, 0);
|
||||
console.log("scout-store sintesi: ok");
|
||||
Reference in new issue
Block a user