Apre il voto MVP due ore dopo l'inizio e lo riserva ai presenti.

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:11:26 +02:00
co-authored by Claude Opus 5
parent da669db1eb
commit f149d02e9a
8 changed files with 105 additions and 27 deletions
+42 -5
View File
@@ -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 (
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP
</p>
<p className="mt-1 text-xs text-muted-foreground">
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
</p>
</div>
);
}
async function invia(id: string, nome: string) {
if (!io) return;
@@ -47,7 +80,7 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
<button
type="button"
onClick={() => setAperto((v) => !v)}
disabled={!io}
disabled={!puoVotare}
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{mio ? "Cambia voto" : "Vota"}
@@ -67,6 +100,10 @@ export function VotazioneMvp({ matchId }: { matchId: string }) {
</p>
{mio ? (
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
) : !puoVotare ? (
<p className="mt-1 text-xs text-muted-foreground">
Vota chi era presente a questa partita.
</p>
) : null}
{aperto ? (
+10
View File
@@ -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() {
+7 -6
View File
@@ -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,
}));
+7 -7
View File
@@ -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;
+6 -4
View File
@@ -169,14 +169,16 @@ function PartitaDetail() {
</div>
))}
</div>
<p className="mt-3 text-center text-xs font-semibold text-muted-foreground">
MVP eletto dalla squadra
</p>
<VotazioneMvp matchId={match.id} />
</div>
</Section>
) : null}
<Section titolo="MVP eletto dalla squadra">
<div className="rounded-3xl bg-card p-5 shadow-card">
<VotazioneMvp evento={evento} />
</div>
</Section>
{match ? (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />