7 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 9d047a6ecd Documentazione: DD-051 e DD-052, modulo MVP, database e changelog
Voto MVP definitivo, a pari merito, segreto e a tempo; nome del votato dalla rosa. PROJECT_STATE
segna che m35-m38 restano da applicare su Supabase (db push rinviato) e il lavoro su feature/gara.

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

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

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

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

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

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

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

No files matched your search

+22 -14
View File
@@ -1,6 +1,6 @@
# Project State # Project State
Ultimo aggiornamento: 06/10/2026 Ultimo aggiornamento: 26/10/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo [docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
@@ -9,27 +9,35 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — ogni evento può avere un link - **Versione:** 1.4.2 in produzione su `main` (tag `v1.4.2`): ogni evento può avere un link Google
Google Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro
convocati si vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si
loro. La 1.4.0: avviso «Presenza modificata» ad admin e vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0 mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md). presenze sui convocati, gli avvisi in Home ridisegnati e i caratteri più grandi. Dettaglio in
[docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più. Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 45 file in `supabase/migrations/`, fino a `m34_link_maps_evento` (colonna `eventi_app.link_maps`, applicata il 26/10/2026). Tutte applicate in produzione (verificato con `npx supabase migration list` il - **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione
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 sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato. `npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`**
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
per scelta, non è stato eseguito. Vanno applicate subito prima del rilascio del codice: con le sole
migration il voto MVP dell'app in produzione (1.4.2) smette di funzionare, con il solo codice la
vista dei conteggi non esiste.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026 - **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo ([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `main` dalla 1.3.0. è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano - **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018). collega al primo accesso (DD-018).
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in - **Lavoro in corso:** branch `feature/gara` per le funzioni di gara (correzioni e nuove
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
## Cose da sapere ## Cose da sapere
+23
View File
@@ -13,6 +13,29 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [Non rilasciato]
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
### Modificato
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
può più cambiare. Il database lo impone (solo gli admin possono correggere un voto).
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
Home e Classifica compaiono i nomi uniti («Anna e Bruno»). Resta il minimo di 2 voti per eleggere
un MVP.
- Il voto MVP è segreto ([DD-052](decisions/DD-052.md)): nessuno vede chi ha votato chi, solo i
conteggi e il proprio voto.
- La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le
partite più vecchie non si votano più.
### Sicurezza
- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per
una partita, tra presenti (`presente` o `ritardo`) e dopo 2 ore dall'inizio (`m36`,
[DD-051](decisions/DD-051.md)). Gli admin possono ancora correggere un voto.
- Il nome del votato lo scrive il database dalla rosa, per i voti MVP (`m37`) e per i badge social
(`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In
produzione non risultavano voti MVP, quindi nessun dato da bonificare.
## [1.4.2] - 2026-10-26 ## [1.4.2] - 2026-10-26
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
+20 -20
View File
@@ -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 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). sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
| Tabella | Chi può scrivere | | Tabella | Chi può scrivere |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | | `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | | `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | | `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | | `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | | `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) | | `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (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) | | `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. | | `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) | | `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) | | `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) | | `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) | | `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()`. 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. La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
@@ -95,11 +95,11 @@ Colonne di `giocatori_squadra` che meritano una nota:
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato, 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). votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- | | ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. | | `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). |
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. | | `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. | | `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
## Turni e notifiche ## Turni e notifiche
+45
View File
@@ -0,0 +1,45 @@
# DD-051 — Voto MVP definitivo e MVP a pari merito
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m35` e `m36`)
**Contesto**
Il voto MVP si poteva cambiare senza limiti e senza storico (`mvp.md`), e una parità in testa non
assegnava l'MVP a nessuno finché non arrivavano altri voti. Con voti modificabili la classifica di una
partita poteva ribaltarsi dopo che la squadra l'aveva già vista; e una parità, che a fine serata non si
risolve più perché nessuno vota ancora, lasciava la partita senza MVP per sempre.
**Decisione**
- **Il voto è definitivo.** Il giocatore lo registra una volta sola per partita. L'interfaccia chiede una
conferma prima di inviarlo e non offre più «Cambia voto»; la mutazione è un `INSERT` e un secondo voto
viene rifiutato dal vincolo `UNIQUE (match_id, votante_id)`. A database (`m35`) il giocatore ha solo la
policy di `INSERT`: `UPDATE` e `DELETE` restano agli admin (policy di M11), che possono correggere un
voto sbagliato.
- **Una parità in testa dà più MVP.** `mvpPartita()` restituisce tutti i più votati; ognuno conta un
titolo in `mvpVintiPerGiocatore()`, e dove si mostra un solo nome per partita (Home, Classifica)
i nomi sono uniti («Anna e Bruno»). Il quorum di DD-028 (`VOTI_MINIMI_MVP = 2`) resta: sotto due voti
totali nessuno è MVP.
- **Presenza e orario a database (`m36`).** Il voto MVP richiede, oltre a convocati e non-allenatori, che
l'evento sia una partita, che votante e votato siano `presente` o `ritardo` e che siano passate 2 ore
dall'inizio. Sta in una funzione dedicata, `voto_mvp_consentito()`, perché `evento_permette_voto()` è
condivisa con pagelle e badge social, che non hanno queste regole. Gli admin restano esclusi dai
vincoli.
**Alternative scartate**
- Lasciare il voto modificabile entro una scadenza → serve una scadenza che non esiste (`mvp.md`,
«Evoluzioni possibili») e un cambio di idea tardivo resta possibile.
- Spareggio con un criterio automatico (media pagella, presenze) → introduce regole nascoste che i
giocatori non vedono; il pari merito è il risultato più semplice da spiegare.
**Conseguenze**
- Un voto dato per errore si corregge solo da un admin.
- I voti già dati restano, ma le partite con una parità in testa assegnano ora i titoli: i conteggi MVP
dei giocatori possono salire (badge «MVP» e «Uomo tie-break» compresi).
- Con tre o più pari merito (per esempio tre voti a tre persone diverse) gli MVP sono tre.
**Riesame**
Se i pari merito risultano troppo frequenti, alzare il quorum (DD-028) o introdurre uno spareggio.
+44
View File
@@ -0,0 +1,44 @@
# DD-052 — Voto MVP segreto, nome dal database e votazione che si chiude
**Data:** 26 ottobre 2026
**Stato:** Accettata (migration `m37` e `m38`)
**Contesto**
Dopo DD-051 il voto è definitivo e controllato a database, ma restavano tre difetti: chiunque poteva
leggere da `mvp_voti` chi aveva votato chi; `votato_nome` lo scriveva il client e poteva non
corrispondere a `votato_id` (il titolo andava all'id, il nome mostrato era un altro); e la votazione,
una volta aperta, non si chiudeva mai.
**Decisione**
- **Voto segreto.** La lettura diretta di `mvp_voti` è del solo votante (il proprio voto) e degli admin.
La squadra legge i conteggi dalla vista `mvp_voti_anonimi`, una riga per voto senza `votante_id` e
ordinata per candidato (la sequenza di arrivo non rivela nulla). La vista gira con i permessi del
proprietario: è ciò che le permette di leggere tutte le righe senza esporre chi ha votato. Non è
leggibile senza account.
- **Nome dal database.** Un trigger riempie `votato_nome` da `giocatori_squadra` a partire da
`votato_id`, ignorando quello inviato (anche se omesso, vuoto o diverso; e anche quando un admin corregge
un voto). Se l'id non è in rosa resta il nome ricevuto. Lo stesso trigger vale per i badge social
(`m38`), che avevano lo stesso difetto; le pagelle non hanno un nome del votato.
- **Chiusura.** Il voto si apre 2 ore dopo l'inizio della partita e si chiude 24 ore dopo l'apertura
(cioè 26 ore dopo l'inizio). A database lo impone `voto_mvp_consentito()`; nell'app `votoMvpChiuso()`.
Gli admin restano fuori dal vincolo.
- **Tutte le partite.** Il voto vale per ogni evento di tipo partita, amichevoli comprese (invariato).
**Alternative scartate**
- Filtrare i voti solo nell'app → chi scrive su PostgREST li leggerebbe comunque.
- Togliere la lettura anche agli admin → non potrebbero più correggere un voto sbagliato; restano gli
unici a poter leggere la tabella, e l'app non mostra a nessuno chi ha votato chi.
- Chiudere 24 ore dopo l'inizio della partita (22 ore di voto) → si è scelto 24 ore piene dall'apertura.
**Conseguenze**
- Le partite già chiuse da più di 26 ore non si votano più: chi non aveva votato perde la possibilità.
I voti già dati restano e contano.
- I conteggi restano visibili durante la votazione: chi vota può vedere chi è in testa.
- Un admin che legge la tabella dal database vede ancora chi ha votato chi.
**Riesame**
Se si vuole un voto davvero cieco, nascondere i risultati fino alla chiusura (la chiusura a 24 ore lo
rende possibile).
+2
View File
@@ -87,6 +87,8 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
| [DD-039](DD-039.md) | Niente promemoria all'allenatore | | [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici | | [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento | | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-047](DD-047.md) | Notifica di compleanno |
+38 -19
View File
@@ -2,6 +2,7 @@
**Stato:** implementato **Stato:** implementato
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx` **File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge`
--- ---
@@ -15,7 +16,10 @@ calcolato a runtime.
## Dati ## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
partita, sovrascrivibile. partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il
proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`,
`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a
partire da `votato_id`: il nome inviato dal client è ignorato.
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la `match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
@@ -27,44 +31,59 @@ restano nel database ma non vengono più letti da nessuna schermata).
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()` - Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
quando aprirà. Nessun legame con il risultato caricato. quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura
(DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il
pulsante «Vota». Nessun legame con il risultato caricato.
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in - Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco. `usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza - `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
limiti, senza storico. tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X».
- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di
confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il
giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo - Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive `mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle. direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità, - `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti. (DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali - `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
incoronare nessuno, nemmeno senza concorrenza. incoronare nessuno, nemmeno senza concorrenza.
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il - `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile (anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home. nel profilo e in home.
--- ---
## Limiti noti ## Limiti noti
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. - Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse:
chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database.
- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di
database vedono chi ha votato chi; l'app non lo mostra a nessuno.
- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una
possibile evoluzione.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`). vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non = tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti** sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto vale mezzanotte). Sono le stesse
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque. e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella (`m37`); un test unitario li tiene allineati. Gli
partita. admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo
al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta.
- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa,
invece, assegna l'MVP a tutti i pari merito.
- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo.
--- ---
## Evoluzioni possibili ## Evoluzioni possibili
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita). - Nascondere i risultati finché la votazione non è chiusa.
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
+82 -44
View File
@@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi";
import { import {
conteggioPartita, conteggioPartita,
mioVoto, mioVoto,
mvpPartita,
ORE_ATTESA_MVP, ORE_ATTESA_MVP,
useMieiVotiMvp,
useVotaMvp, useVotaMvp,
useVotiMvp, useVotiMvp,
VOTI_MINIMI_MVP,
votoMvpAperto, votoMvpAperto,
type VotoMvp, votoMvpChiuso,
DURATA_VOTO_MVP_ORE,
type VotoMvpPubblico,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
/** /**
* Pannello di votazione MVP di una partita: un voto a testa, modificabile. * Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
* testa gli MVP sono più d'uno.
* *
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche * I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili * senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
@@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo(); const io = useGiocatoreInCampo();
const voti = useVotiMvp(); const voti = useVotiMvp();
const miei = useMieiVotiMvp(io?.id);
const vota = useVotaMvp(); const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id)); const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null);
const tutti: VotoMvp[] = voti.data ?? []; const tutti: VotoMvpPubblico[] = voti.data ?? [];
const conteggio = conteggioPartita(tutti, matchId); const conteggio = conteggioPartita(tutti, matchId);
const mio = io ? mioVoto(tutti, matchId, io.id) : null; const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
const chiuso = votoMvpChiuso(evento.data, evento.ora);
const totale = conteggio.reduce((s, c) => s + c.voti, 0); const totale = conteggio.reduce((s, c) => s + c.voti, 0);
const testa = conteggio[0]; const mvp = mvpPartita(tutti, matchId);
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti; const puoVotare = !!io && !mio && !chiuso && presente(io.id);
const puoVotare = !!io && presente(io.id);
if (!votoMvpAperto(evento.data, evento.ora)) { if (!votoMvpAperto(evento.data, evento.ora)) {
return ( return (
@@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
votato_nome: nome, votato_nome: nome,
}); });
setAperto(false); setAperto(false);
setScelto(null);
toast.success(`Voto MVP registrato: ${nome}`); toast.success(`Voto MVP registrato: ${nome}`);
} catch { } catch {
toast.error("Voto non riuscito, riprova"); toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
} }
} }
@@ -78,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"> <p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"} <Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
</p> </p>
<button {mio || chiuso ? null : (
type="button" <button
onClick={() => setAperto((v) => !v)} type="button"
disabled={!puoVotare} onClick={() => setAperto((v) => !v)}
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50" 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> Vota
</button>
)}
</div> </div>
<p className="mt-2 text-[13px]"> <p className="mt-2 text-[13px]">
{testa && !pareggio ? ( {mvp.length > 0 ? (
<span className="inline-flex items-center gap-1 font-bold"> <span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti}) {mvp.map((c) => (
<span key={c.id} className="inline-flex items-center gap-1">
<Crown className="h-3.5 w-3.5 text-warning" /> {c.nome} ({c.voti})
</span>
))}
</span> </span>
) : ( ) : (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"} {totale === 0
? "Nessun voto: MVP da eleggere"
: `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`}
</span> </span>
)} )}
</p> </p>
{mio ? ( {mio ? (
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p> <p className="mt-1 text-[13px] text-muted-foreground">
Hai votato {mio.votato_nome}: il voto non si può cambiare.
</p>
) : chiuso ? (
<p className="mt-1 text-[13px] text-muted-foreground">Votazione chiusa.</p>
) : !puoVotare ? ( ) : !puoVotare ? (
<p className="mt-1 text-[13px] text-muted-foreground"> <p className="mt-1 text-[13px] text-muted-foreground">
Vota chi era presente a questa partita. Vota chi era presente a questa partita.
</p> </p>
) : null} ) : (
<p className="mt-1 text-[13px] text-muted-foreground">
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
</p>
)}
{aperto ? ( {aperto && !mio ? (
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto"> <>
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato <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). */} anche dal vincolo `mvp_no_autovoto` (M12). */}
{rosa {rosa
.filter((g) => g.id !== io?.id) .filter((g) => g.id !== io?.id)
.map((g) => ( .map((g) => (
<button <button
key={g.id} key={g.id}
type="button" type="button"
disabled={vota.isPending} disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))} onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
className={cn( className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors", "truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
mio?.votato_id === g.id scelto?.id === g.id
? "bg-accent text-accent-foreground" ? "bg-accent text-accent-foreground"
: "bg-card text-foreground", : "bg-card text-foreground",
)} )}
> >
{nomeCompleto(g)} {nomeCompleto(g)}
</button> </button>
))} ))}
</div> </div>
<button
type="button"
disabled={!scelto || vota.isPending}
onClick={() => scelto && invia(scelto.id, scelto.nome)}
className="mt-2 w-full rounded-full bg-accent px-3 py-2 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{scelto ? `Conferma il voto per ${scelto.nome}` : "Scegli chi votare"}
</button>
<p className="mt-1 text-center text-[13px] text-muted-foreground">
Il voto è definitivo: non si può cambiare.
</p>
</>
) : conteggio.length > 0 ? ( ) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 flex flex-wrap gap-1.5">
{conteggio.map((c) => ( {conteggio.map((c) => (
+8 -1
View File
@@ -625,7 +625,14 @@ export type Database = {
}; };
}; };
Views: { Views: {
[_ in never]: never; mvp_voti_anonimi: {
Row: {
match_id: string | null;
votato_id: string | null;
votato_nome: string | null;
};
Relationships: [];
};
}; };
Functions: { Functions: {
has_role: { has_role: {
+79 -47
View File
@@ -8,52 +8,91 @@ export type VotoMvp = {
votato_nome: string; votato_nome: string;
}; };
/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */
export type VotoMvpPubblico = Pick<VotoMvp, "match_id" | "votato_id" | "votato_nome">;
const CHIAVE = ["mvp-voti"] as const; const CHIAVE = ["mvp-voti"] as const;
const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const;
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */ /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
export const ORE_ATTESA_MVP = 2; export const ORE_ATTESA_MVP = 2;
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */ /** Per quante ore resta aperto il voto, dall'apertura (DD-052). */
export const DURATA_VOTO_MVP_ORE = 24;
/** Il voto MVP apre 2 ore dopo l'inizio della partita. */
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean { export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`); const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false; if (Number.isNaN(inizio.getTime())) return false;
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000; return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
} }
/** Tutti i voti MVP della squadra (poche righe, si carica tutto). /** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */
export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean {
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
if (Number.isNaN(inizio.getTime())) return false;
const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE;
return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000;
}
/** Si può votare adesso: aperto e non ancora chiuso. */
export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean {
return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso);
}
/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto).
* Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052).
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */ * Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
export function useVotiMvp() { export function useVotiMvp() {
return useQuery({ return useQuery({
queryKey: CHIAVE, queryKey: CHIAVE,
staleTime: 10 * 60_000, staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvpPubblico[]> => {
const { data, error } = await supabase
.from("mvp_voti_anonimi")
.select("match_id, votato_id, votato_nome");
if (error) throw error;
return (data ?? []) as VotoMvpPubblico[];
},
});
}
/** I voti che ho dato io: sono gli unici che la tabella mi lascia leggere. */
export function useMieiVotiMvp(votanteId: string | undefined) {
return useQuery({
queryKey: chiaveMiei(votanteId),
enabled: !!votanteId,
staleTime: 10 * 60_000,
queryFn: async (): Promise<VotoMvp[]> => { queryFn: async (): Promise<VotoMvp[]> => {
const { data, error } = await supabase const { data, error } = await supabase
.from("mvp_voti") .from("mvp_voti")
.select("match_id, votante_id, votato_id, votato_nome"); .select("match_id, votante_id, votato_id, votato_nome")
.eq("votante_id", votanteId!);
if (error) throw error; if (error) throw error;
return (data ?? []) as VotoMvp[]; return (data ?? []) as VotoMvp[];
}, },
}); });
} }
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
export function useVotaMvp() { export function useVotaMvp() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (voto: VotoMvp) => { mutationFn: async (voto: VotoMvp) => {
const { error } = await supabase const { error } = await supabase.from("mvp_voti").insert(voto);
.from("mvp_voti")
.upsert(voto, { onConflict: "match_id,votante_id" });
if (error) throw error; if (error) throw error;
return voto; return voto;
}, },
// Aggiorna la cache localmente: nessuna rilettura dal database. // Aggiorna la cache localmente: nessuna rilettura dal database.
onSuccess: (voto) => { onSuccess: (voto) => {
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => { qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
const altri = (prec ?? []).filter( ...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id), voto,
); ]);
return [...altri, voto]; qc.setQueryData<VotoMvpPubblico[]>(CHIAVE, (prec) => [
}); ...(prec ?? []),
{ match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome },
]);
}, },
}); });
} }
@@ -64,7 +103,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number };
export const VOTI_MINIMI_MVP = 2; export const VOTI_MINIMI_MVP = 2;
/** Conteggio voti di una partita, dal più votato. */ /** Conteggio voti di una partita, dal più votato. */
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>(); const map = new Map<string, ConteggioMvp>();
for (const v of voti) { for (const v of voti) {
if (v.match_id !== matchId) continue; if (v.match_id !== matchId) continue;
@@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome)); return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
} }
/** Vincitore per ogni partita votata: matchId -> nome MVP. */ /**
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> { * MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
const perMatch = new Map<string, VotoMvp[]>(); * merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
for (const v of voti) { */
const arr = perMatch.get(v.match_id) ?? []; export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
arr.push(v); const top = conteggioPartita(voti, matchId);
perMatch.set(v.match_id, arr); 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> = {}; const out: Record<string, string> = {};
for (const [matchId] of perMatch) { for (const matchId of new Set(voti.map((v) => v.match_id))) {
const top = conteggioPartita(voti, matchId); const mvp = mvpPartita(voti, matchId);
const totaleVoti = top.reduce((s, c) => s + c.voti, 0); if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
// 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;
}
} }
return out; return out;
} }
@@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
} }
/** /**
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio * MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti * parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
* restituisce una mappa vuota. * restituisce una mappa vuota.
*/ */
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> { export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
const out: Record<string, number> = {}; const out: Record<string, number> = {};
const matchIds = new Set(voti.map((v) => v.match_id)); for (const matchId of new Set(voti.map((v) => v.match_id))) {
for (const matchId of matchIds) { for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
const top = conteggioPartita(voti, matchId);
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
if (
totaleVoti >= VOTI_MINIMI_MVP &&
top.length > 0 &&
(top.length === 1 || top[0]!.voti > top[1]!.voti)
) {
const id = top[0]!.id;
out[id] = (out[id] ?? 0) + 1;
}
} }
return out; return out;
} }
@@ -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();
+29
View File
@@ -0,0 +1,29 @@
/**
* Data e ora (fuso Europe/Rome) di un istante a `ore` ore da adesso, nel formato di `eventi_app`
* (`data` AAAA-MM-GG, `ora` HH:MM). Serve ai test del voto MVP, che dipende dall'orario di inizio.
*/
export function romaTra(ore: number): { data: string; ora: string } {
const p: Record<string, string> = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(new Date(Date.now() + ore * 3_600_000))
.map((x) => [x.type, x.value]),
);
return { data: `${p["year"]}-${p["month"]}-${p["day"]}`, ora: `${p["hour"]}:${p["minute"]}` };
}
/**
* Una data con ora vuota vale mezzanotte. Ritorna il giorno la cui mezzanotte cade tra 2 e 26 ore
* fa (il voto è aperto): oggi se sono passate almeno 2 ore dalla mezzanotte, altrimenti ieri.
*/
export function giornoSenzaOraAperto(): string {
const adesso = romaTra(0);
return Number(adesso.ora.slice(0, 2)) >= 2 ? adesso.data : romaTra(-24).data;
}
+12 -14
View File
@@ -14,7 +14,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges"; import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -52,11 +52,11 @@ if (!locale) {
} }
/** Rilegge esattamente come `useVotiMvp()`. */ /** Rilegge esattamente come `useVotiMvp()`. */
async function leggiVoti(): Promise<VotoMvp[]> { async function leggiVoti(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */ /** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
@@ -68,8 +68,8 @@ if (!locale) {
await prova( await prova(
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database", "il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
async () => { async () => {
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in // Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, // voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge. // non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({ await upsert({
match_id: `${PREFISSO}-m1`, match_id: `${PREFISSO}-m1`,
@@ -107,17 +107,15 @@ if (!locale) {
votato_id: "vg1", votato_id: "vg1",
votato_nome: "Uno", votato_nome: "Uno",
}); });
await upsert({
match_id: `${PREFISSO}-m3`,
votante_id: "vb",
votato_id: "vg2",
votato_nome: "Due",
});
const voti = await leggiVoti(); const voti = await leggiVoti();
const vinti = mvpVintiPerGiocatore(voti); const vinti = mvpVintiPerGiocatore(voti);
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)"); assert.equal(
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); vinti["vg1"],
2,
"vg1 vince m1 e m2, m3 ha un solo voto (sotto quorum, non conta)",
);
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0)); const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
+28 -1
View File
@@ -8,6 +8,7 @@
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato), * Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`. * lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
*/ */
import { romaTra } from "../helpers/roma";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
@@ -259,8 +260,34 @@ if (!locale) {
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque. // Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, { await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ convocati: [] }), // M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
}); });
// M36: perché il rifiuto dipenda solo dal ruolo, tutti sono presenti (lo slot dell'allenatore
// lo segniamo presente a mano: da sé non potrebbe rispondere) e la partita è passata.
for (const g of [GIOCATORE, SLOT, "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: g, stato: "presente" }),
});
}
// Controllo positivo: due giocatori presenti, il voto passa.
const valido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO,
votante_id: GIOCATORE,
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(
await righeToccate(valido),
1,
`un giocatore vota un giocatore (${valido.status})`,
);
const vota = await rest("mvp_voti", tokenAllenatore, { const vota = await rest("mvp_voti", tokenAllenatore, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
+365 -10
View File
@@ -16,6 +16,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
import { prova, riepilogo, salta } from "../helpers/prova"; import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale(); const locale = statoLocale();
@@ -310,12 +311,47 @@ if (!locale) {
}), }),
}); });
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
});
// --- Voto MVP: definitivo (M35), solo tra presenti e dopo 2 ore dall'inizio (M36) ----------
let contatoreMvp = 0;
/** Crea una partita (o altro tipo) con le presenze indicate; ritorna l'id. */
async function eventoMvp(
quando: { data: string; ora: string },
presenze: Record<string, string>,
tipo = "partita",
): Promise<string> {
const id = `${PREFISSO}-mvp-${++contatoreMvp}`;
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ id, tipo, titolo: "Voto MVP", luogo: "Palestra", ...quando }),
});
assert.equal(await righeToccate(creato), 1, "l'evento di prova si crea");
for (const [giocatore_id, stato] of Object.entries(presenze)) {
const r = await rest("risposte_presenze", SERVIZIO, {
method: "POST",
body: JSON.stringify({ evento_id: id, giocatore_id, stato }),
});
assert.ok(r.ok, `presenza di prova ${giocatore_id}: ${r.status}`);
}
return id;
}
const votaMvp = (token: string, match_id: string, votante_id: string, votato_id: string) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id, votato_id, votato_nome: votato_id }),
});
const passata = romaTra(-3);
await prova("il voto MVP è uno solo, definitivo e firmato (M35)", async () => {
const EVENTO_MVP = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const mioMvp = await rest("mvp_voti", tokenGiocatore, { const mioMvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g1", votante_id: "g1",
votato_id: "g5", votato_id: "g5",
votato_nome: "Cinque", votato_nome: "Cinque",
@@ -323,11 +359,55 @@ if (!locale) {
}); });
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra"); assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
// Il voto MVP è definitivo (M35): né un secondo voto, né una modifica, né la cancellazione.
const secondo = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
votante_id: "g1",
votato_id: "g6",
votato_nome: "Sei",
}),
});
assert.ok(
!secondo.ok,
`un secondo voto MVP sulla stessa partita è rifiutato (${secondo.status})`,
);
const modifica = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Sei" }),
},
);
assert.equal(await righeToccate(modifica), 0, "il proprio voto MVP non si modifica");
const cancella = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
tokenGiocatore,
{
method: "DELETE",
headers: { Prefer: "return=representation" },
},
);
assert.equal(await righeToccate(cancella), 0, "il proprio voto MVP non si cancella");
const dopo = await rest(
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1&select=votato_id`,
SERVIZIO,
);
assert.deepEqual(
await dopo.json(),
[{ votato_id: "g5" }],
"il voto è rimasto quello iniziale",
);
const mvp = await rest("mvp_voti", tokenGiocatore, { const mvp = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO, match_id: EVENTO_MVP,
votante_id: "g5", votante_id: "g5",
votato_id: "g1", votato_id: "g1",
votato_nome: "Uno", votato_nome: "Uno",
@@ -336,6 +416,265 @@ if (!locale) {
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`); assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
}); });
await prova("il voto MVP vale solo tra presenti (M36)", async () => {
const q = passata;
// Controllo positivo: presente e in ritardo possono votare e farsi votare.
const ok = await eventoMvp(q, { g1: "presente", g5: "ritardo" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, ok, "g1", "g5")), 1);
const okRitardo = await eventoMvp(q, { g1: "ritardo", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, okRitardo, "g1", "g5")), 1);
for (const [stato, nome] of [
["forse", "forse"],
["assente", "assente"],
["infortunato", "infortunato"],
[null, "senza risposta"],
] as const) {
const votatoNo = await eventoMvp(q, {
g1: "presente",
...(stato ? { g5: stato } : {}),
});
const r = await votaMvp(tokenGiocatore, votatoNo, "g1", "g5");
assert.ok(!r.ok, `un votato ${nome} non si può votare (${r.status})`);
const votanteNo = await eventoMvp(q, {
g5: "presente",
...(stato ? { g1: stato } : {}),
});
const r2 = await votaMvp(tokenGiocatore, votanteNo, "g1", "g5");
assert.ok(!r2.ok, `un votante ${nome} non può votare (${r2.status})`);
}
});
await prova(
"il voto MVP apre 2 ore dopo l'inizio e chiude 24 ore dopo (M36, M37)",
async () => {
const presenti = { g1: "presente", g5: "presente" };
const tre = await eventoMvp(romaTra(-3), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, tre, "g1", "g5")),
1,
"3 ore dopo",
);
const una = await eventoMvp(romaTra(-1), presenti);
const rUna = await votaMvp(tokenGiocatore, una, "g1", "g5");
assert.ok(!rUna.ok, `1 ora dopo l'inizio: ancora chiuso (${rUna.status})`);
const venticinque = await eventoMvp(romaTra(-25), presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, venticinque, "g1", "g5")),
1,
"25 ore dopo l'inizio: ancora aperto (chiude a 26)",
);
const ventisette = await eventoMvp(romaTra(-27), presenti);
const rChiuso = await votaMvp(tokenGiocatore, ventisette, "g1", "g5");
assert.ok(!rChiuso.ok, `27 ore dopo l'inizio: chiuso (${rChiuso.status})`);
const futura = await eventoMvp(romaTra(48), presenti);
const rFutura = await votaMvp(tokenGiocatore, futura, "g1", "g5");
assert.ok(!rFutura.ok, `partita non ancora giocata (${rFutura.status})`);
// Un'ora vuota vale mezzanotte, come nell'app.
const senzaOra = await eventoMvp({ data: giornoSenzaOraAperto(), ora: "" }, presenti);
assert.equal(
await righeToccate(await votaMvp(tokenGiocatore, senzaOra, "g1", "g5")),
1,
"senza ora vale mezzanotte",
);
},
);
await prova("il voto MVP esiste solo per le partite (M36)", async () => {
const presenti = { g1: "presente", g5: "presente" };
for (const tipo of ["allenamento", "evento"]) {
const e = await eventoMvp(passata, presenti, tipo);
const r = await votaMvp(tokenGiocatore, e, "g1", "g5");
assert.ok(!r.ok, `nessun MVP per ${tipo} (${r.status})`);
}
});
await prova("l'admin può registrare un voto MVP anche fuori da queste regole", async () => {
const futura = await eventoMvp(romaTra(48), { g1: "assente" });
const r = await votaMvp(tokenAdmin, futura, "g1", "g5");
assert.equal(await righeToccate(r), 1, "l'admin corregge/inserisce senza vincoli di M36");
const chiusa = await eventoMvp(romaTra(-48), { g1: "assente" });
const rc = await votaMvp(tokenAdmin, chiusa, "g1", "g5");
assert.equal(await righeToccate(rc), 1, "l'admin può votare anche a votazione chiusa");
});
await prova("il voto MVP è segreto: nessuno legge chi ha votato chi (M37)", async () => {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, e, "g1", "g5")), 1);
// Un secondo voto di un altro votante (inserito dall'admin) per avere due righe.
assert.equal(await righeToccate(await votaMvp(tokenAdmin, e, "g5", "g1")), 1);
const tabella = await rest(
`mvp_voti?match_id=eq.${e}&select=votante_id,votato_id`,
tokenGiocatore,
);
assert.deepEqual(
await tabella.json(),
[{ votante_id: "g1", votato_id: "g5" }],
"dalla tabella un giocatore legge solo il proprio voto",
);
const vista = await rest(`mvp_voti_anonimi?match_id=eq.${e}&select=*`, tokenGiocatore);
assert.ok(vista.ok, `la vista dei conteggi si legge (${vista.status})`);
const righe = (await vista.json()) as Record<string, string>[];
assert.deepEqual(
righe.map((r) => r["votato_id"]).sort(),
["g1", "g5"],
"ci sono tutti i voti",
);
assert.ok(
righe.every((r) => !("votante_id" in r)),
"ma la vista non dice chi li ha dati",
);
const anonimo = await rest(`mvp_voti_anonimi?match_id=eq.${e}`, ANON);
assert.ok(!anonimo.ok, `senza account non si legge nemmeno la vista (${anonimo.status})`);
const anonimoTabella = await rest(`mvp_voti?match_id=eq.${e}`, ANON);
const corpo = anonimoTabella.ok ? ((await anonimoTabella.json()) as unknown[]) : [];
assert.equal(corpo.length, 0, "né la tabella");
const admin = await rest(`mvp_voti?match_id=eq.${e}&select=votante_id`, tokenAdmin);
assert.equal(
((await admin.json()) as unknown[]).length,
2,
"l'admin vede i voti dalla tabella: gli servono per correggerli",
);
});
/** Nome in rosa di uno slot, come lo compone il trigger (nome + cognome, senza spazi in testa). */
async function nomeInRosa(id: string): Promise<string> {
const r = await rest(`giocatori_squadra?id=eq.${id}&select=nome,cognome`, SERVIZIO);
const [g] = (await r.json()) as { nome: string; cognome: string }[];
return `${g!.nome} ${g!.cognome}`.trim();
}
const votoNome = (token: string, match_id: string, votato_id: string, resto: object) =>
rest("mvp_voti", token, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ match_id, votante_id: "g1", votato_id, ...resto }),
});
const nomeRiga = async (res: Response) =>
((await res.json()) as { votato_nome: string }[])[0]?.votato_nome;
await prova("MVP: il nome del votato lo mette il database, non il client (M37)", async () => {
const g5 = await nomeInRosa("g5");
// Nome inventato: ignorato.
const falso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(
await votoNome(tokenGiocatore, falso, "g5", { votato_nome: "Nome Inventato" }),
),
g5,
);
const visto = await rest(
`mvp_voti_anonimi?match_id=eq.${falso}&select=votato_nome`,
tokenGiocatore,
);
assert.deepEqual(await visto.json(), [{ votato_nome: g5 }], "e così lo vede la squadra");
// Nome omesso (la colonna è NOT NULL): lo riempie il trigger, il voto passa.
const omesso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, omesso, "g5", {})),
g5,
"nome omesso",
);
// Nome vuoto, con spazi, con un altro giocatore: sempre quello della rosa.
for (const [i, strano] of ["", " ", await nomeInRosa("g1"), "<b>x</b>"].entries()) {
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, e, "g5", { votato_nome: strano })),
g5,
`caso ${i}`,
);
}
// Nome già corretto: resta lo stesso.
const giusto = await eventoMvp(passata, { g1: "presente", g5: "presente" });
assert.equal(
await nomeRiga(await votoNome(tokenGiocatore, giusto, "g5", { votato_nome: g5 })),
g5,
);
});
await prova(
"MVP: l'admin che corregge un voto non può far divergere il nome (M37)",
async () => {
const [g5, g6] = [await nomeInRosa("g5"), await nomeInRosa("g6")];
const e = await eventoMvp(passata, { g1: "presente", g5: "presente", g6: "presente" });
await votoNome(tokenGiocatore, e, "g5", {});
const filtro = `mvp_voti?match_id=eq.${e}&votante_id=eq.g1`;
// Cambia solo il nome: torna quello della rosa.
const soloNome = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(soloNome), g5, "nome riscritto da solo");
// Cambia il votato: il nome lo segue.
const altro = await rest(filtro, tokenAdmin, {
method: "PATCH",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ votato_id: "g6", votato_nome: "Falso" }),
});
assert.equal(await nomeRiga(altro), g6, "nome che segue il nuovo votato");
},
);
await prova("MVP: un id fuori rosa conserva il nome ricevuto (M37)", async () => {
// Solo la service role (come i test sui badge) può scrivere id che non sono in rosa.
const e = `${PREFISSO}-mvp-fuori-rosa`;
const r = await rest("mvp_voti", SERVIZIO, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
match_id: e,
votante_id: "xx1",
votato_id: "xx2",
votato_nome: "Ospite",
}),
});
assert.equal(await nomeRiga(r), "Ospite", "niente da sostituire: resta il nome ricevuto");
});
await prova("badge social: il nome del votato lo mette il database (M38)", async () => {
const g5 = await nomeInRosa("g5");
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
const base = { match_id: e, categoria: "sorriso", votante_id: "g1", votato_id: "g5" };
const post = (resto: object) =>
rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ ...base, ...resto }),
});
assert.equal(
await nomeRiga(await post({ votato_nome: "Nome Inventato" })),
g5,
"nome inventato",
);
// Stesso voto rinviato con un altro nome (upsert): il nome resta quello della rosa.
assert.equal(
await nomeRiga(await post({ votato_nome: "Un Altro" })),
g5,
"upsert con altro nome",
);
const omesso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({ ...base, categoria: "meme" }),
});
assert.equal(await nomeRiga(omesso), g5, "nome omesso");
});
await prova("le cacche le dichiara il diretto interessato", async () => { await prova("le cacche le dichiara il diretto interessato", async () => {
const mie = await rest("cacche_partita", tokenGiocatore, { const mie = await rest("cacche_partita", tokenGiocatore, {
method: "POST", method: "POST",
@@ -455,8 +794,8 @@ if (!locale) {
id: EVENTO_CON_G1, id: EVENTO_CON_G1,
tipo: "partita", tipo: "partita",
titolo: "Partita con g1 convocato, g2 no", titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
convocati: ["g1", "g5"], convocati: ["g1", "g5"],
}), }),
@@ -482,9 +821,17 @@ if (!locale) {
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui // Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo // convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto. // (es. un evento inesistente) e sembrare comunque corretto.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CON_G1, giocatore_id: g, stato: "presente" }),
});
}
const votoValido = await rest("mvp_voti", tokenGiocatore, { const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CON_G1, match_id: EVENTO_CON_G1,
votante_id: "g1", votante_id: "g1",
@@ -503,8 +850,8 @@ if (!locale) {
id: EVENTO_CHIUSO, id: EVENTO_CHIUSO,
tipo: "partita", tipo: "partita",
titolo: "Partita con pagelle chiuse", titolo: "Partita con pagelle chiuse",
data: "2026-01-03", data: romaTra(-3).data,
ora: "20:00", ora: romaTra(-3).ora,
luogo: "Palestra", luogo: "Palestra",
pagelle_chiuse: true, pagelle_chiuse: true,
}), }),
@@ -529,9 +876,17 @@ if (!locale) {
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di // Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano // "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento. // votabili sullo stesso evento.
// M36: il voto MVP richiede che votante e votato siano presenti.
for (const g of ["g1", "g5"]) {
await rest("risposte_presenze", SERVIZIO, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({ evento_id: EVENTO_CHIUSO, giocatore_id: g, stato: "presente" }),
});
}
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, { const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST", method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" }, headers: { Prefer: "return=representation" },
body: JSON.stringify({ body: JSON.stringify({
match_id: EVENTO_CHIUSO, match_id: EVENTO_CHIUSO,
votante_id: "g1", votante_id: "g1",
+4 -4
View File
@@ -20,7 +20,7 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle"; import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data"; import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale"; import { statoLocale } from "../helpers/locale";
@@ -67,11 +67,11 @@ if (!locale) {
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`); if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
} }
async function leggiMvp(): Promise<VotoMvp[]> { async function leggiMvp(): Promise<VotoMvpPubblico[]> {
const res = await rest( const res = await rest(
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
); );
return (await res.json()) as VotoMvp[]; return (await res.json()) as VotoMvpPubblico[];
} }
async function leggiPagelle(): Promise<VotoPagella[]> { async function leggiPagelle(): Promise<VotoPagella[]> {
+71 -7
View File
@@ -1,11 +1,18 @@
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { import {
conteggioPartita, conteggioPartita,
elencoNomi,
mioVoto, mioVoto,
mvpPartita,
mvpVintiPerGiocatore, mvpVintiPerGiocatore,
DURATA_VOTO_MVP_ORE,
ORE_ATTESA_MVP,
vincitoriMvp, vincitoriMvp,
votoMvpAperto, votoMvpAperto,
votoMvpChiuso,
votoMvpInCorso,
VOTI_MINIMI_MVP, VOTI_MINIMI_MVP,
type VotoMvp, type VotoMvp,
} from "@/lib/mvp-voti"; } from "@/lib/mvp-voti";
@@ -44,9 +51,13 @@ assert.deepEqual(
["Anna", "Zeno"], ["Anna", "Zeno"],
); );
// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- // --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); assert.deepEqual(
vincitoriMvp(pari),
{ m3: "Anna e Zeno" },
"due voti pari in testa: sono MVP entrambi, a pari merito",
);
assert.deepEqual(vincitoriMvp([]), {}); assert.deepEqual(vincitoriMvp([]), {});
// --- vincitoriMvp: quorum minimo di voti ------------------------------------- // --- vincitoriMvp: quorum minimo di voti -------------------------------------
@@ -69,13 +80,38 @@ const triplaPari = [
v("m6", "g2", "g8", "Anna"), v("m6", "g2", "g8", "Anna"),
v("m6", "g3", "g7", "Bea"), v("m6", "g3", "g7", "Bea"),
]; ];
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); assert.deepEqual(
vincitoriMvp(triplaPari),
{ m6: "Anna, Bea e Zeno" },
"tre pari in testa: tre MVP, in ordine alfabetico",
);
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore(triplaPari), mvpVintiPerGiocatore(triplaPari),
{}, { g9: 1, g8: 1, g7: 1 },
"stessa parità: nessuna vittoria netta da contare", "ognuno dei pari merito conta un titolo",
); );
// Primo pari col secondo, terzo staccato: il terzo resta fuori.
const duePariUnoSotto = [
v("m7", "g1", "g9", "Zeno"),
v("m7", "g2", "g9", "Zeno"),
v("m7", "g3", "g8", "Anna"),
v("m7", "g4", "g8", "Anna"),
v("m7", "g5", "g7", "Bea"),
];
assert.deepEqual(
mvpPartita(duePariUnoSotto, "m7").map((c) => c.nome),
["Anna", "Zeno"],
);
assert.deepEqual(mvpPartita([v("m8", "g1", "g2", "Solo")], "m8"), [], "sotto quorum: nessuno");
assert.deepEqual(mvpPartita([], "m8"), []);
// --- elencoNomi ---
assert.equal(elencoNomi([]), "");
assert.equal(elencoNomi(["Anna"]), "Anna");
assert.equal(elencoNomi(["Anna", "Bruno"]), "Anna e Bruno");
assert.equal(elencoNomi(["Anna", "Bruno", "Carla"]), "Anna, Bruno e Carla");
// --- mioVoto ----------------------------------------------------------------- // --- mioVoto -----------------------------------------------------------------
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
@@ -89,8 +125,8 @@ assert.deepEqual(
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, ...pari]), mvpVintiPerGiocatore([...partita, ...pari]),
{ g2: 1, g5: 1 }, { g2: 1, g5: 1, g9: 1, g8: 1 },
"la parità non assegna", "m3 è pari: i due pari merito prendono un titolo ciascuno",
); );
assert.deepEqual( assert.deepEqual(
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
@@ -114,4 +150,32 @@ assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita pa
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte"); assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true); assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
// --- votoMvpChiuso: 24 ore dopo l'apertura (2 ore dopo l'inizio) -----------------
assert.equal(DURATA_VOTO_MVP_ORE, 24);
const giorno = (g: number, o: number, m = 0) => new Date(2026, 8, g, o, m);
// partita del 5/9 alle 20:30: apre il 5 alle 22:30, chiude il 6 alle 22:30
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 29)), false, "un minuto prima");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 30)), true, "24 ore in punto");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(20, 12)), true, "partita vecchia");
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(5, 12)), false, "prima della partita");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 21)), false, "non ancora aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 23)), true, "aperto");
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(7, 8)), false, "chiuso");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 1, 59)), false, "senza ora vale mezzanotte");
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 2)), true);
// --- l'app e il database (M36, M37) hanno la stessa finestra -----------------------
const m37 = readFileSync(
"supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql",
"utf8",
);
assert.ok(
m37.includes(`interval '${ORE_ATTESA_MVP} hours'`),
"ORE_ATTESA_MVP e l'attesa di voto_mvp_consentito() devono coincidere",
);
assert.ok(
m37.includes(`interval '${DURATA_VOTO_MVP_ORE} hours'`),
"DURATA_VOTO_MVP_ORE e la chiusura di voto_mvp_consentito() devono coincidere",
);
console.log("mvp-voti: ok"); console.log("mvp-voti: ok");