From f149d02e9a28d12d23cc72851bd56d0845118aaf Mon Sep 17 00:00:00 2001
From: Davide Grilli
Date: Sun, 6 Sep 2026 22:11:26 +0200
Subject: [PATCH] Apre il voto MVP due ore dopo l'inizio e lo riserva ai
presenti.
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La votazione non dipendeva dal tempo ma dal risultato: compariva solo con un
referto CSI o uno scout salvato, e chiunque avesse un profilo poteva votare.
Ora `votoMvpAperto()` la apre due ore dopo `data`+`ora` dell'evento, il pannello
sta in una sezione sua e votano — e sono votabili — solo i presenti (o in
ritardo) di quell'evento.
I voti passano dall'id scout/CSI a quello dell'evento CrAPP: home e classifica
leggono il vincitore tramite la mappa data → evento che avevano già. Le regole
sono applicative, non RLS: chi scrive su PostgREST le aggira, come già annotato
tra i limiti noti del modulo.
Co-Authored-By: Claude Opus 5
---
docs/CHANGELOG.md | 9 +++++
docs/modules/mvp.md | 18 +++++++---
src/components/crapp/VotazioneMvp.tsx | 47 ++++++++++++++++++++++++---
src/lib/mvp-voti.ts | 10 ++++++
src/routes/classifica.tsx | 13 ++++----
src/routes/index.tsx | 14 ++++----
src/routes/partita.$id.tsx | 10 +++---
test/unit/mvp-voti.test.ts | 11 +++++++
8 files changed, 105 insertions(+), 27 deletions(-)
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 7b9cb04..19c65c9 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -6,6 +6,15 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
## Versione attuale — agosto 2026
+### Il voto MVP apre due ore dopo il fischio d'inizio, e vota solo chi c'era
+
+- La votazione non dipende più dal referto CSI né dallo Scout: il pannello ha una sezione sua
+ in `/partita/$id` e si apre due ore dopo `data`+`ora` dell'evento, anche senza risultato
+ caricato.
+- Votano — e sono votabili — solo i giocatori dati `presente` o `ritardo` a quell'evento.
+- I voti sono ora legati all'id dell'evento CrAPP: le righe scritte prima con l'id dello
+ scout o della partita CSI restano nel database ma non vengono più lette.
+
### Tre riletture in meno dopo ogni salvataggio
- «Aggiungi giocatore», salvataggio ed eliminazione di una partita scoutata e cambio della
diff --git a/docs/modules/mvp.md b/docs/modules/mvp.md
index a9e7a57..8d11dbf 100644
--- a/docs/modules/mvp.md
+++ b/docs/modules/mvp.md
@@ -17,12 +17,19 @@ calcolato a runtime.
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
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
+restano nel database ma non vengono più letti da nessuna schermata).
+
---
## Implementazione
-- Il pannello compare in `partita.$id.tsx` solo se esiste un risultato (Scout Live salvato)
- per la partita, altrimenti mostra "la partita non è ancora stata disputata".
+- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
+ lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
+ quando aprirà. Nessun legame con il risultato caricato.
+- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
+ `usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
limiti, senza storico.
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
@@ -38,11 +45,12 @@ partita, sovrascrivibile.
## Limiti noti
-- Nessuna scadenza o chiusura della votazione: resta aperta indefinitamente.
+- 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 è che non sia il votante stesso (`mvp_no_autovoto`): che sia un convocato di
- quella partita resta un filtro solo applicativo.
+ vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero
+ presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo
+ applicativi — chi scrive su PostgREST li aggira.
- In caso di parità, nessun MVP viene assegnato per quella partita.
---
diff --git a/src/components/crapp/VotazioneMvp.tsx b/src/components/crapp/VotazioneMvp.tsx
index 87f7601..a19753c 100644
--- a/src/components/crapp/VotazioneMvp.tsx
+++ b/src/components/crapp/VotazioneMvp.tsx
@@ -4,15 +4,34 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreCorrente } from "@/lib/user-store";
-import { conteggioPartita, mioVoto, useVotaMvp, useVotiMvp, type VotoMvp } from "@/lib/mvp-voti";
+import { usePresenzeEvento } from "@/lib/presenze";
+import type { Evento } from "@/lib/eventi";
+import {
+ conteggioPartita,
+ mioVoto,
+ ORE_ATTESA_MVP,
+ useVotaMvp,
+ useVotiMvp,
+ votoMvpAperto,
+ type VotoMvp,
+} from "@/lib/mvp-voti";
-/** Pannello di votazione MVP di una partita: un voto a testa, modificabile. */
-export function VotazioneMvp({ matchId }: { matchId: string }) {
+/**
+ * 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
+ * solo i presenti (o in ritardo) di quell'evento.
+ */
+export function VotazioneMvp({ evento }: { evento: Evento }) {
+ const matchId = evento.id;
const io = useGiocatoreCorrente();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
- const rosa = squadra.filter((g) => g.attivo);
+ const { risposte } = usePresenzeEvento(evento.id);
+ const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
+ const rosa = squadra.filter((g) => g.attivo && presente(g.id));
const [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? [];
@@ -21,6 +40,20 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
const testa = conteggio[0];
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
+ const puoVotare = !!io && presente(io.id);
+
+ if (!votoMvpAperto(evento.data, evento.ora)) {
+ return (
+
+
+ Voto MVP
+
+
+ Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
+
+
+ );
+ }
async function invia(id: string, nome: string) {
if (!io) return;
@@ -47,7 +80,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
{mio ? (
Hai votato {mio.votato_nome}
+ ) : !puoVotare ? (
+
+ Vota chi era presente a questa partita.
+
) : null}
{aperto ? (
diff --git a/src/lib/mvp-voti.ts b/src/lib/mvp-voti.ts
index acf5c45..a6afff5 100644
--- a/src/lib/mvp-voti.ts
+++ b/src/lib/mvp-voti.ts
@@ -10,6 +10,16 @@ export type VotoMvp = {
const CHIAVE = ["mvp-voti"] as const;
+/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
+export const ORE_ATTESA_MVP = 2;
+
+/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
+export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
+ const inizio = new Date(`${data}T${ora || "00:00"}:00`);
+ if (Number.isNaN(inizio.getTime())) return false;
+ return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
+}
+
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
export function useVotiMvp() {
diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx
index bfb8416..5e6b914 100644
--- a/src/routes/classifica.tsx
+++ b/src/routes/classifica.tsx
@@ -56,12 +56,13 @@ function Classifica() {
const eventoIdPerData = new Map(
eventi.filter((e) => e.tipo === "partita").map((e) => [e.data, e.id]),
);
+ // I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
+ const mvpPerData = (data: string) => mvpPerMatch[eventoIdPerData.get(data) ?? ""] ?? "";
const tuttiMatch = csiGiocate.length
- ? csiGiocate.map((p) => ({
- ...matchDaPartitaCsi(p),
- mvp: mvpPerMatch[p.id] ?? "",
- scout: false,
- }))
+ ? csiGiocate.map((p) => {
+ const m = matchDaPartitaCsi(p);
+ return { ...m, mvp: mvpPerData(m.data), scout: false };
+ })
: scoutMatches.map((m) => ({
id: m.id,
data: m.data,
@@ -70,7 +71,7 @@ function Classifica() {
setNostri: m.setNostri,
setLoro: m.setLoro,
parziali: m.parziali,
- mvp: mvpPerMatch[m.id] ?? "",
+ mvp: mvpPerData(m.data),
scout: true,
}));
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 81659d5..f0c1dc8 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -49,15 +49,15 @@ function Index() {
const votiMvp = useVotiMvp();
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
- const ultima = csiGiocate[0]
- ? { ...matchDaPartitaCsi(csiGiocate[0]), mvp: mvpPerMatch[csiGiocate[0].id] ?? "" }
- : scoutMatches[0]
- ? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? "" }
- : null;
+ const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null);
const obiettivi = useObiettivi();
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
- const eventoUltima = ultima
- ? (eventi.find((e) => e.tipo === "partita" && e.data === ultima.data) ?? null)
+ const eventoUltima = ultimaBase
+ ? (eventi.find((e) => e.tipo === "partita" && e.data === ultimaBase.data) ?? null)
+ : null;
+ // I voti MVP sono legati all'evento CrAPP, non al referto CSI né allo scout.
+ const ultima = ultimaBase
+ ? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
: null;
if (!giocatore) return null;
diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx
index d08931e..c91805f 100644
--- a/src/routes/partita.$id.tsx
+++ b/src/routes/partita.$id.tsx
@@ -169,14 +169,16 @@ function PartitaDetail() {
))}
-
- MVP eletto dalla squadra
-
-
) : null}
+
+
+
+
+
+
{match ? (
diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts
index a5d175d..fef42f0 100644
--- a/test/unit/mvp-voti.test.ts
+++ b/test/unit/mvp-voti.test.ts
@@ -5,6 +5,7 @@ import {
mioVoto,
mvpVintiPerGiocatore,
vincitoriMvp,
+ votoMvpAperto,
type VotoMvp,
} from "@/lib/mvp-voti";
@@ -74,4 +75,14 @@ assert.deepEqual(
);
assert.deepEqual(mvpVintiPerGiocatore([]), {});
+// --- votoMvpAperto: due ore dopo il fischio d'inizio -------------------------
+const alle = (o: number, m = 0) => new Date(2026, 8, 5, o, m);
+assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(22, 29)), false, "un minuto prima");
+assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(22, 30)), true, "due ore in punto");
+assert.equal(votoMvpAperto("2026-09-05", "20:30", alle(23)), true);
+assert.equal(votoMvpAperto("2026-09-06", "10:00", alle(23)), false, "partita di domani");
+assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita passata: aperto");
+assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
+assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
+
console.log("mvp-voti: ok");