2 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 c26b275e5c Porta la versione a 1.5.0: campionato CSI 2026/27
Changelog, PROJECT_STATE e package.json/lock alla 1.5.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:26:17 +01:00
davideandClaude Sonnet 5.5 a378a795a4 Campionato CSI: stagione 2026/27
Nuovo project_id (933) e team_id (3878) sul portale Livescore. La Coppa non
esiste quest'anno: CSI_COPPA_PROJECT_ID diventa null e la route non la scarica
piu'. Lo storico 2025/26 non viene piu' mostrato. Test live adattati a inizio
stagione (nessuna gara giocata), doc e changelog aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:18:23 +01:00
27 changed files with 281 additions and 1107 deletions

No files matched your search

+15 -22
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 26/10/2026
Ultimo aggiornamento: 06/10/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,35 +9,28 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale
- **Versione:** 1.4.2 in produzione su `main` (tag `v1.4.2`): ogni evento può avere un link Google
Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro
non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si
vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
mostra solo i prossimi compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta
lampo» e «Mai un forfait». La 1.3.0 portava la notifica di compleanno, gli obiettivi cliccabili, le
presenze sui convocati, gli avvisi in Home ridisegnati e i caratteri più grandi. Dettaglio in
[docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Versione:** 1.5.0 (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).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione
sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con
`npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`**
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
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.
- **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.
- **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:** branch `feature/gara` per le funzioni di gara (correzioni e nuove
funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
## Cose da sapere
+10 -17
View File
@@ -13,28 +13,21 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
## [1.5.0] - 2026-10-29
Campionato CSI 2026/27. Nessuna migration.
### 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ù.
- Campionato CSI passato alla stagione 2026/27: nuovo `project_id` (933) e nuovo `team_id`
(3878). La Coppa non c'è quest'anno e la sezione resta nascosta; lo storico 2025/26 non viene
più mostrato.
### Sicurezza
### Dati
- 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.
- Calendario di produzione aggiornato a mano con le 12 gare di campionato già definite dal CSI
(4/11/2026 – 11/3/2027): titolo «CAMPIONATO», luogo e link Google Maps dell'impianto. I 9
allenamenti nello stesso giorno di una gara sono stati eliminati.
## [1.4.2] - 2026-10-26
+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
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. 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) |
| 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) |
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.
@@ -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,
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`; 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`). |
| 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`. |
## Turni e notifiche
+1 -1
View File
@@ -31,7 +31,7 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Collegamento CSI (stagione 2026/27)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
-45
View File
@@ -1,45 +0,0 @@
# 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
@@ -1,44 +0,0 @@
# 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,8 +87,6 @@ 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-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
+38 -38
View File
@@ -1,6 +1,6 @@
# Modulo — Collegamento CSI
**Stato:** implementato (stagione 2025/26)
**Stato:** implementato (stagione 2026/27)
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
(dettaglio di una gara: formazioni e scontri diretti)
@@ -28,52 +28,50 @@ verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati i
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un `project_id`), nessun codice le chiama direttamente.
### Identificativi (stagione 2025/26)
### Identificativi (stagione 2026/27)
| Cosa | Valore |
| --------------------- | -------------------------------------- |
| Campionato | PVM - Campionato Open Misto Eccellenza |
| `project_id` (girone) | `767` |
| Coppa | PVM Coppa CSI Misto Silver |
| `project_id` (coppa) | `848` |
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
| `team_id` | `3359` |
| Girone | B |
| `project_id` (girone) | `933` |
| Coppa | nessuna: la squadra non è iscritta |
| `project_id` (coppa) | `null` |
| Squadra sul portale | `C.R.A.P. Volley` |
| `team_id` | `3878` |
| Girone | B (7 squadre) |
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
`project_id` (933), `CSI_COPPA_PROJECT_ID` (`null`) e `team_id` (3878) sono costanti in
`src/lib/csi-core.ts`.
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27 ([DD-032](../decisions/DD-032.md)).
**A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
`3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
`3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
classifica è tutta a zero (giocate 0): non è un errore di parsing.
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
`team_details.php` nel browser, questo componente basta.
Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
`team_id` si legge dal link della squadra nella pagina del girone) oppure
`components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
`933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
`CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
### Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `components/project-sheets.php?project_id=933` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=933` (tab "Classifica") |
| `components/project-sheets.php?project_id=<Coppa>` | HTML | Classifica del girone di Coppa, solo se `CSI_COPPA_PROJECT_ID` non è `null` (vedi limite 4) | `league_details.php?project_id=<Coppa>` (tab "Classifica") |
| `assets/json/getEventsByTeamId.php?team_id=3878` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3878` (tab "Calendario", `team-calendar.php`) |
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
(`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
condivisa, nessun parser dedicato per la Coppa.
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
@@ -247,9 +245,10 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
`csi-core.ts`, e lo storico della stagione precedente sparisce (DD-032 non lo conserva).
L'etichetta «Stagione 2026/27» di `/classifica` invece non va toccata: il portale non
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
@@ -258,20 +257,21 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
dati in una tabella Supabase riempita da un job cron (stesso pattern di
`promemoria-palloni`).
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
`getEventsByTeamId.php?team_id=3878` è per squadra, non per competizione (vedi tabella
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
**La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
`/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
2025/26 era `project_id=848`: `project-sheets.php` ha la stessa
struttura a tabella-per-girone del campionato, quindi `parseClassifica()` funziona
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
`project-main.php?project_id=848` (2025/26) descrive il regolamento — "due gironi sola andata, le
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
+19 -38
View File
@@ -2,7 +2,6 @@
**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`
---
@@ -16,10 +15,7 @@ calcolato a runtime.
## Dati
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
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.
partita, sovrascrivibile.
`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
@@ -31,59 +27,44 @@ 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à; `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.
quando aprirà. 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.
- `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.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico.
- 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.
- `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
- `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
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 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
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
nel profilo e in home.
---
## Limiti noti
- 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.
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- 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). 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.
= 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.
---
## Evoluzioni possibili
- Nascondere i risultati finché la votazione non è chiusa.
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.4.2",
"version": "1.5.0",
"private": true,
"sideEffects": false,
"type": "module",
+44 -82
View File
@@ -9,21 +9,15 @@ import type { Evento } from "@/lib/eventi";
import {
conteggioPartita,
mioVoto,
mvpPartita,
ORE_ATTESA_MVP,
useMieiVotiMvp,
useVotaMvp,
useVotiMvp,
VOTI_MINIMI_MVP,
votoMvpAperto,
votoMvpChiuso,
DURATA_VOTO_MVP_ORE,
type VotoMvpPubblico,
type VotoMvp,
} from "@/lib/mvp-voti";
/**
* Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
* testa gli MVP sono più d'uno.
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
*
* 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
@@ -34,22 +28,20 @@ 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: VotoMvpPubblico[] = voti.data ?? [];
const tutti: VotoMvp[] = voti.data ?? [];
const conteggio = conteggioPartita(tutti, matchId);
const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
const chiuso = votoMvpChiuso(evento.data, evento.ora);
const mio = io ? mioVoto(tutti, matchId, io.id) : null;
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
const mvp = mvpPartita(tutti, matchId);
const puoVotare = !!io && !mio && !chiuso && presente(io.id);
const testa = conteggio[0];
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
const puoVotare = !!io && presente(io.id);
if (!votoMvpAperto(evento.data, evento.ora)) {
return (
@@ -74,10 +66,9 @@ 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: se hai già votato, il voto non si può cambiare");
toast.error("Voto non riuscito, riprova");
}
}
@@ -87,87 +78,58 @@ 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>
{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>
)}
<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>
</div>
<p className="mt-2 text-[13px]">
{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>
))}
{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})
</span>
) : (
<span className="text-muted-foreground">
{totale === 0
? "Nessun voto: MVP da eleggere"
: `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`}
{totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"}
</span>
)}
</p>
{mio ? (
<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>
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
) : !puoVotare ? (
<p className="mt-1 text-[13px] text-muted-foreground">
Vota chi era presente a questa partita.
</p>
) : (
<p className="mt-1 text-[13px] text-muted-foreground">
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
</p>
)}
) : null}
{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
{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
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={() => 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>
</>
{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>
) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{conteggio.map((c) => (
+1 -8
View File
@@ -625,14 +625,7 @@ export type Database = {
};
};
Views: {
mvp_voti_anonimi: {
Row: {
match_id: string | null;
votato_id: string | null;
votato_nome: string | null;
};
Relationships: [];
};
[_ in never]: never;
};
Functions: {
has_role: {
+10 -9
View File
@@ -8,16 +8,17 @@ import type { RigaClassifica } from "./crapp-data";
*/
export const CSI_BASE = "https://livescore.csibologna.it";
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 767;
/** Campionato Open Misto Eccellenza 2026/27. Cambia a ogni stagione. */
export const CSI_PROJECT_ID = 933;
/**
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
* Coppa CSI della stagione in corso, `null` se la squadra non è iscritta (nel 2026/27 non c'è).
* Stesso formato di tabella del campionato (girone all'italiana), solo con meno squadre:
* `parseClassifica()` funziona invariata. Cambia a ogni stagione come CSI_PROJECT_ID, vedi
* docs/modules/collegamento-csi.md.
*/
export const CSI_COPPA_PROJECT_ID = 848;
/** C.R.A.P. Volley sul portale CSI. */
export const CSI_TEAM_ID = 3359;
export const CSI_COPPA_PROJECT_ID: number | null = null;
/** C.R.A.P. Volley sul portale CSI: l'id cambia a ogni stagione (2025/26 era 3359). */
export const CSI_TEAM_ID = 3878;
export const CSI_GIRONE = "Girone B";
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
@@ -133,7 +134,7 @@ export type DettaglioPartitaCsi = {
export type DatiCsi = {
classifica: RigaClassifica[];
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
/** Classifica del girone di Coppa (`CSI_COPPA_PROJECT_ID`). Vuota se il parsing fallisce. */
classificaCoppa: RigaClassifica[];
partite: PartitaCsi[];
girone: string;
+47 -79
View File
@@ -8,91 +8,52 @@ 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;
/** 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. */
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
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;
}
/** 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).
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
* 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")
.eq("votante_id", votanteId!);
.select("match_id, votante_id, votato_id, votato_nome");
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").insert(voto);
const { error } = await supabase
.from("mvp_voti")
.upsert(voto, { onConflict: "match_id,votante_id" });
if (error) throw error;
return voto;
},
// Aggiorna la cache localmente: nessuna rilettura dal database.
onSuccess: (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 },
]);
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];
});
},
});
}
@@ -103,7 +64,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: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
const map = new Map<string, ConteggioMvp>();
for (const v of voti) {
if (v.match_id !== matchId) continue;
@@ -114,29 +75,26 @@ export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): Cont
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
}
/**
* 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> {
/** 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);
}
const out: Record<string, string> = {};
for (const matchId of new Set(voti.map((v) => v.match_id))) {
const mvp = mvpPartita(voti, matchId);
if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
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;
}
}
return out;
}
@@ -146,14 +104,24 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
}
/**
* 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
* 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
* restituisce una mappa vuota.
*/
export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
const out: Record<string, number> = {};
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;
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;
}
}
return out;
}
+3 -1
View File
@@ -33,7 +33,9 @@ async function leggiCsi(): Promise<DatiCsi> {
scarica(urlClassifica()),
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
// come classifica/partite del girone (vedi il controllo sotto).
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
scarica(urlPartite()),
]);
const classifica = parseClassifica(html);
@@ -1,18 +0,0 @@
-- 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)
);
@@ -1,48 +0,0 @@
-- 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)
);
@@ -1,89 +0,0 @@
-- 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;
$$;
@@ -1,11 +0,0 @@
-- 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
@@ -1,29 +0,0 @@
/**
* 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;
}
+14 -12
View File
@@ -14,7 +14,7 @@
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mvpVintiPerGiocatore, type VotoMvp } 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<VotoMvpPubblico[]> {
async function leggiVoti(): Promise<VotoMvp[]> {
const res = await rest(
`mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoMvpPubblico[];
return (await res.json()) as VotoMvp[];
}
/** 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), in m3 ha un solo
// voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
// 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,
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
await upsert({
match_id: `${PREFISSO}-m1`,
@@ -107,15 +107,17 @@ 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, m3 ha un solo voto (sotto quorum, non conta)",
);
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
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");
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
+1 -28
View File
@@ -8,7 +8,6 @@
* 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";
@@ -260,34 +259,8 @@ 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",
// M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
body: JSON.stringify({ convocati: [] }),
});
// 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({
+10 -365
View File
@@ -16,7 +16,6 @@
*/
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();
@@ -311,47 +310,12 @@ 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: "return=representation" },
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
match_id: EVENTO,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
@@ -359,55 +323,11 @@ 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: "return=representation" },
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_MVP,
match_id: EVENTO,
votante_id: "g5",
votato_id: "g1",
votato_nome: "Uno",
@@ -416,265 +336,6 @@ 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",
@@ -794,8 +455,8 @@ if (!locale) {
id: EVENTO_CON_G1,
tipo: "partita",
titolo: "Partita con g1 convocato, g2 no",
data: romaTra(-3).data,
ora: romaTra(-3).ora,
data: "2026-01-02",
ora: "20:00",
luogo: "Palestra",
convocati: ["g1", "g5"],
}),
@@ -821,17 +482,9 @@ 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: "return=representation" },
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CON_G1,
votante_id: "g1",
@@ -850,8 +503,8 @@ if (!locale) {
id: EVENTO_CHIUSO,
tipo: "partita",
titolo: "Partita con pagelle chiuse",
data: romaTra(-3).data,
ora: romaTra(-3).ora,
data: "2026-01-03",
ora: "20:00",
luogo: "Palestra",
pagelle_chiuse: true,
}),
@@ -876,17 +529,9 @@ 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: "return=representation" },
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CHIUSO,
votante_id: "g1",
+4 -4
View File
@@ -20,7 +20,7 @@
*/
import assert from "node:assert/strict";
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
import { mvpVintiPerGiocatore, type VotoMvp } 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<VotoMvpPubblico[]> {
async function leggiMvp(): Promise<VotoMvp[]> {
const res = await rest(
`mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
);
return (await res.json()) as VotoMvpPubblico[];
return (await res.json()) as VotoMvp[];
}
async function leggiPagelle(): Promise<VotoPagella[]> {
+34 -23
View File
@@ -333,42 +333,51 @@ assert.equal(
if (process.env["CSI_LIVE"]) {
const [html, htmlCoppa, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()),
fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()),
CSI_COPPA_PROJECT_ID === null
? Promise.resolve("")
: fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()),
fetch(urlPartite()).then((r) => r.json()),
]);
const live = parseClassifica(html);
const nostra = live.find((r) => isNostraSquadra(r.squadra));
assert.ok(nostra, "la nostra squadra è in classifica");
assert.ok(live.length >= 8 && nostra.giocate > 0);
assert.ok(live.length >= 7); // a inizio stagione le giocate sono ancora 0
const liveCoppa = parseClassifica(htmlCoppa);
assert.ok(
liveCoppa.some((r) => isNostraSquadra(r.squadra)),
"la nostra squadra è nel girone di Coppa",
);
if (CSI_COPPA_PROJECT_ID !== null) {
assert.ok(
liveCoppa.some((r) => isNostraSquadra(r.squadra)),
"la nostra squadra è nel girone di Coppa",
);
}
const livePartite = partiteDaEventi(json);
const liveGiocate = partiteGiocate(livePartite);
assert.ok(livePartite.length > 0 && liveGiocate.length > 0);
assert.ok(livePartite.length > 0, "calendario presente"); // liveGiocate può essere vuoto a inizio stagione
assert.ok(
liveGiocate.every((p) => p.link.startsWith("https://")),
"ogni partita ha un referto",
);
const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
]);
const liveFormazioni = parseFormazioni(livePlayers);
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
const livePrecedenti = parsePrecedenti(liveStats);
assert.ok(livePrecedenti, "precedenti riconosciuti");
console.log(
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
);
// A inizio stagione nessuna gara è ancora giocata: niente referto da leggere.
if (liveGiocate.length === 0) {
console.log(`live: ${live.length} squadre, ${livePartite.length} partite, nessuna giocata`);
} else {
const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
]);
const liveFormazioni = parseFormazioni(livePlayers);
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
const livePrecedenti = parsePrecedenti(liveStats);
assert.ok(livePrecedenti, "precedenti riconosciuti");
console.log(
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
);
}
}
// --- stagioneDi / stagioneDaPartite: l'anno cambia ad agosto ------------------
@@ -377,6 +386,8 @@ assert.equal(stagioneDi("2026-05-20"), "2025/26");
assert.equal(stagioneDi("2026-07-31"), "2025/26");
assert.equal(stagioneDi("2026-08-01"), "2026/27");
assert.equal(stagioneDi("2099-10-01"), "2099/00");
assert.equal(stagioneDi("2026-11-04"), "2026/27", "prima gara del campionato 2026/27");
assert.equal(stagioneDi("2027-03-11"), "2026/27", "ultima gara del campionato 2026/27");
assert.equal(stagioneDaPartite([]), null);
assert.equal(
stagioneDaPartite([{ data: "2026-03-04" }, { data: "2025-11-12" }, { data: "" }]),
+7 -71
View File
@@ -1,18 +1,11 @@
/** 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";
@@ -51,13 +44,9 @@ assert.deepEqual(
["Anna", "Zeno"],
);
// --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
// --- vincitoriMvp: la parità non assegna nessun MVP --------------------------
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
assert.deepEqual(
vincitoriMvp(pari),
{ m3: "Anna e Zeno" },
"due voti pari in testa: sono MVP entrambi, a pari merito",
);
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato");
assert.deepEqual(vincitoriMvp([]), {});
// --- vincitoriMvp: quorum minimo di voti -------------------------------------
@@ -80,38 +69,13 @@ const triplaPari = [
v("m6", "g2", "g8", "Anna"),
v("m6", "g3", "g7", "Bea"),
];
assert.deepEqual(
vincitoriMvp(triplaPari),
{ m6: "Anna, Bea e Zeno" },
"tre pari in testa: tre MVP, in ordine alfabetico",
);
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie");
assert.deepEqual(
mvpVintiPerGiocatore(triplaPari),
{ g9: 1, g8: 1, g7: 1 },
"ognuno dei pari merito conta un titolo",
{},
"stessa parità: nessuna vittoria netta da contare",
);
// 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");
@@ -125,8 +89,8 @@ assert.deepEqual(
);
assert.deepEqual(
mvpVintiPerGiocatore([...partita, ...pari]),
{ g2: 1, g5: 1, g9: 1, g8: 1 },
"m3 è pari: i due pari merito prendono un titolo ciascuno",
{ g2: 1, g5: 1 },
"la parità non assegna",
);
assert.deepEqual(
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
@@ -150,32 +114,4 @@ 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");