From 127d5cb5b828c7d9fa91900867e25d117a181b47 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH] Voto MVP: definitivo, a pari merito, segreto e a tempo nell'app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il pannello chiede conferma e non permette di cambiare il voto; con una parità in testa gli MVP sono più d'uno; i conteggi vengono dalla vista mvp_voti_anonimi e il proprio voto dalla tabella; il voto si chiude 24 ore dopo l'apertura. Co-Authored-By: Claude Sonnet 5.5 --- src/components/crapp/VotazioneMvp.tsx | 126 +++++++++++++++++--------- src/integrations/supabase/types.ts | 9 +- src/lib/mvp-voti.ts | 126 ++++++++++++++++---------- 3 files changed, 169 insertions(+), 92 deletions(-) diff --git a/src/components/crapp/VotazioneMvp.tsx b/src/components/crapp/VotazioneMvp.tsx index 08adfb7..4d86e46 100644 --- a/src/components/crapp/VotazioneMvp.tsx +++ b/src/components/crapp/VotazioneMvp.tsx @@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi"; import { conteggioPartita, mioVoto, + mvpPartita, ORE_ATTESA_MVP, + useMieiVotiMvp, useVotaMvp, useVotiMvp, + VOTI_MINIMI_MVP, votoMvpAperto, - type VotoMvp, + votoMvpChiuso, + DURATA_VOTO_MVP_ORE, + type VotoMvpPubblico, } from "@/lib/mvp-voti"; /** - * Pannello di votazione MVP di una partita: un voto a testa, modificabile. + * Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in + * testa gli MVP sono più d'uno. * * I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche * senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili @@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). const io = useGiocatoreInCampo(); const voti = useVotiMvp(); + const miei = useMieiVotiMvp(io?.id); const vota = useVotaMvp(); const { righe: squadra } = useGiocatoriSquadra(); const { risposte } = usePresenzeEvento(evento.id); const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; const rosa = squadra.filter((g) => inRosa(g) && presente(g.id)); const [aperto, setAperto] = useState(false); + const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null); - const tutti: VotoMvp[] = voti.data ?? []; + const tutti: VotoMvpPubblico[] = voti.data ?? []; const conteggio = conteggioPartita(tutti, matchId); - const mio = io ? mioVoto(tutti, matchId, io.id) : null; + const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null; + const chiuso = votoMvpChiuso(evento.data, evento.ora); const totale = conteggio.reduce((s, c) => s + c.voti, 0); - const testa = conteggio[0]; - const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti; - const puoVotare = !!io && presente(io.id); + const mvp = mvpPartita(tutti, matchId); + const puoVotare = !!io && !mio && !chiuso && presente(io.id); if (!votoMvpAperto(evento.data, evento.ora)) { return ( @@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) { votato_nome: nome, }); setAperto(false); + setScelto(null); toast.success(`Voto MVP registrato: ${nome}`); } catch { - toast.error("Voto non riuscito, riprova"); + toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare"); } } @@ -78,58 +87,87 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {

Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}

- + {mio || chiuso ? null : ( + + )}

- {testa && !pareggio ? ( - - {testa.nome} ({testa.voti}) + {mvp.length > 0 ? ( + + {mvp.map((c) => ( + + {c.nome} ({c.voti}) + + ))} ) : ( - {totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"} + {totale === 0 + ? "Nessun voto: MVP da eleggere" + : `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`} )}

{mio ? ( -

Hai votato {mio.votato_nome}

+

+ Hai votato {mio.votato_nome}: il voto non si può cambiare. +

+ ) : chiuso ? ( +

Votazione chiusa.

) : !puoVotare ? (

Vota chi era presente a questa partita.

- ) : null} + ) : ( +

+ Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore. +

+ )} - {aperto ? ( -
- {/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato + {aperto && !mio ? ( + <> +
+ {/* 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) => ( - - ))} -
+ {rosa + .filter((g) => g.id !== io?.id) + .map((g) => ( + + ))} +
+ +

+ Il voto è definitivo: non si può cambiare. +

+ ) : conteggio.length > 0 ? (
{conteggio.map((c) => ( diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 4fec40f..3205698 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -625,7 +625,14 @@ export type Database = { }; }; Views: { - [_ in never]: never; + mvp_voti_anonimi: { + Row: { + match_id: string | null; + votato_id: string | null; + votato_nome: string | null; + }; + Relationships: []; + }; }; Functions: { has_role: { diff --git a/src/lib/mvp-voti.ts b/src/lib/mvp-voti.ts index d7ebdcc..546b827 100644 --- a/src/lib/mvp-voti.ts +++ b/src/lib/mvp-voti.ts @@ -8,52 +8,91 @@ export type VotoMvp = { votato_nome: string; }; +/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */ +export type VotoMvpPubblico = Pick; + const CHIAVE = ["mvp-voti"] as const; +const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const; /** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */ export const ORE_ATTESA_MVP = 2; -/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */ +/** Per quante ore resta aperto il voto, dall'apertura (DD-052). */ +export const DURATA_VOTO_MVP_ORE = 24; + +/** Il voto MVP apre 2 ore dopo l'inizio della partita. */ export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean { const inizio = new Date(`${data}T${ora || "00:00"}:00`); if (Number.isNaN(inizio.getTime())) return false; return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000; } -/** Tutti i voti MVP della squadra (poche righe, si carica tutto). +/** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */ +export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean { + const inizio = new Date(`${data}T${ora || "00:00"}:00`); + if (Number.isNaN(inizio.getTime())) return false; + const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE; + return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000; +} + +/** Si può votare adesso: aperto e non ancora chiuso. */ +export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean { + return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso); +} + +/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto). + * Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052). * Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */ export function useVotiMvp() { return useQuery({ queryKey: CHIAVE, staleTime: 10 * 60_000, + queryFn: async (): Promise => { + 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 => { const { data, error } = await supabase .from("mvp_voti") - .select("match_id, votante_id, votato_id, votato_nome"); + .select("match_id, votante_id, votato_id, votato_nome") + .eq("votante_id", votanteId!); if (error) throw error; return (data ?? []) as VotoMvp[]; }, }); } +/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */ export function useVotaMvp() { const qc = useQueryClient(); return useMutation({ mutationFn: async (voto: VotoMvp) => { - const { error } = await supabase - .from("mvp_voti") - .upsert(voto, { onConflict: "match_id,votante_id" }); + const { error } = await supabase.from("mvp_voti").insert(voto); if (error) throw error; return voto; }, // Aggiorna la cache localmente: nessuna rilettura dal database. onSuccess: (voto) => { - qc.setQueryData(CHIAVE, (prec) => { - const altri = (prec ?? []).filter( - (v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id), - ); - return [...altri, voto]; - }); + qc.setQueryData(chiaveMiei(voto.votante_id), (prec) => [ + ...(prec ?? []).filter((v) => v.match_id !== voto.match_id), + voto, + ]); + qc.setQueryData(CHIAVE, (prec) => [ + ...(prec ?? []), + { match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome }, + ]); }, }); } @@ -64,7 +103,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number }; export const VOTI_MINIMI_MVP = 2; /** Conteggio voti di una partita, dal più votato. */ -export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] { +export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] { const map = new Map(); for (const v of voti) { if (v.match_id !== matchId) continue; @@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome)); } -/** Vincitore per ogni partita votata: matchId -> nome MVP. */ -export function vincitoriMvp(voti: VotoMvp[]): Record { - const perMatch = new Map(); - for (const v of voti) { - const arr = perMatch.get(v.match_id) ?? []; - arr.push(v); - perMatch.set(v.match_id, arr); - } +/** + * MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari + * merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora. + */ +export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] { + const top = conteggioPartita(voti, matchId); + const totaleVoti = top.reduce((s, c) => s + c.voti, 0); + if (totaleVoti < VOTI_MINIMI_MVP) return []; + return top.filter((c) => c.voti === top[0]!.voti); +} + +/** «Anna», «Anna e Bruno», «Anna, Bruno e Carla». */ +export function elencoNomi(nomi: string[]): string { + if (nomi.length <= 1) return nomi.join(""); + return `${nomi.slice(0, -1).join(", ")} e ${nomi[nomi.length - 1]}`; +} + +/** MVP per ogni partita votata: matchId -> nome (più nomi uniti se c'è una parità in testa). */ +export function vincitoriMvp(voti: VotoMvpPubblico[]): Record { const out: Record = {}; - for (const [matchId] of perMatch) { - const top = conteggioPartita(voti, matchId); - const totaleVoti = top.reduce((s, c) => s + c.voti, 0); - // In caso di parità, o sotto il quorum minimo, nessun MVP assegnato. - if ( - totaleVoti >= VOTI_MINIMI_MVP && - top.length > 0 && - (top.length === 1 || top[0]!.voti > top[1]!.voti) - ) { - out[matchId] = top[0]!.nome; - } + for (const matchId of new Set(voti.map((v) => v.match_id))) { + const mvp = mvpPartita(voti, matchId); + if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome)); } return out; } @@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) { } /** - * MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio - * al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti + * MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di + * parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti * restituisce una mappa vuota. */ -export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record { +export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record { const out: Record = {}; - const matchIds = new Set(voti.map((v) => v.match_id)); - for (const matchId of matchIds) { - const top = conteggioPartita(voti, matchId); - const totaleVoti = top.reduce((s, c) => s + c.voti, 0); - if ( - totaleVoti >= VOTI_MINIMI_MVP && - top.length > 0 && - (top.length === 1 || top[0]!.voti > top[1]!.voti) - ) { - const id = top[0]!.id; - out[id] = (out[id] ?? 0) + 1; - } + for (const matchId of new Set(voti.map((v) => v.match_id))) { + for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1; } return out; }