+ 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) => (
-
- ))}
-
{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;
}