From 215090d2974bbac9cc215acf691ac6ae4040fb0a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 01/14] =?UTF-8?q?Migration=20m35:=20il=20voto=20MVP=20?= =?UTF-8?q?=C3=A8=20definitivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il giocatore può solo inserire il proprio voto: UPDATE e DELETE restano agli admin. Da applicare su Supabase con db push (non ancora fatto). Co-Authored-By: Claude Sonnet 5.5 --- .../20261026150000_m35_voto_mvp_definitivo.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 supabase/migrations/20261026150000_m35_voto_mvp_definitivo.sql diff --git a/supabase/migrations/20261026150000_m35_voto_mvp_definitivo.sql b/supabase/migrations/20261026150000_m35_voto_mvp_definitivo.sql new file mode 100644 index 0000000..9b5ff0a --- /dev/null +++ b/supabase/migrations/20261026150000_m35_voto_mvp_definitivo.sql @@ -0,0 +1,18 @@ +-- 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) + ); From 92e71953578dd0fdaae88c4bb1804fd0ac2552d8 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 02/14] Migration m36: il voto MVP solo tra presenti e dopo 2 ore voto_mvp_consentito() controlla partita, presenza (presente o ritardo) di votante e votato e l'attesa di 2 ore dall'inizio. Da applicare su Supabase con db push (non ancora fatto). Co-Authored-By: Claude Sonnet 5.5 --- ...6160000_m36_voto_mvp_presenti_e_orario.sql | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 supabase/migrations/20261026160000_m36_voto_mvp_presenti_e_orario.sql diff --git a/supabase/migrations/20261026160000_m36_voto_mvp_presenti_e_orario.sql b/supabase/migrations/20261026160000_m36_voto_mvp_presenti_e_orario.sql new file mode 100644 index 0000000..4d00778 --- /dev/null +++ b/supabase/migrations/20261026160000_m36_voto_mvp_presenti_e_orario.sql @@ -0,0 +1,48 @@ +-- 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) + ); From b17605ae769467d6790795202c9668028c84e90f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 03/14] Migration m37: voto MVP segreto, nome dalla rosa, chiusura a 24 ore Lettura diretta di mvp_voti solo del votante e degli admin, conteggi dalla vista mvp_voti_anonimi; trigger che riempie votato_nome dalla rosa; il voto chiude 24 ore dopo l'apertura. Da applicare su Supabase con db push (non ancora fatto). Co-Authored-By: Claude Sonnet 5.5 --- ...170000_m37_voto_mvp_segreto_e_chiusura.sql | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql diff --git a/supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql b/supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql new file mode 100644 index 0000000..258a593 --- /dev/null +++ b/supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql @@ -0,0 +1,89 @@ +-- 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; +$$; From e5fac249166cad5d70af7679a1afd49546c2f993 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 04/14] Migration m38: nome del votato dalla rosa anche per i badge social Stesso difetto dell'MVP: votato_nome lo scriveva il client. Riusa il trigger della m37. Da applicare su Supabase con db push (non ancora fatto). Co-Authored-By: Claude Sonnet 5.5 --- .../20261026180000_m38_nome_votato_badge_social.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 supabase/migrations/20261026180000_m38_nome_votato_badge_social.sql diff --git a/supabase/migrations/20261026180000_m38_nome_votato_badge_social.sql b/supabase/migrations/20261026180000_m38_nome_votato_badge_social.sql new file mode 100644 index 0000000..9a6fc8e --- /dev/null +++ b/supabase/migrations/20261026180000_m38_nome_votato_badge_social.sql @@ -0,0 +1,11 @@ +-- 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(); From 127d5cb5b828c7d9fa91900867e25d117a181b47 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 05/14] 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; } From 5fed10585052e1b330f5b2b933607bbb26bb825f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 06/14] Test del voto MVP: regole a database, segretezza, nome dalla rosa, chiusura MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrazione (permessi, permessi-allenatore, mvp-badge, s-tiebreak-badge), unit (mvp-voti) e helper delle date di Roma. Con le parità l'MVP vale per tutti i pari merito. Co-Authored-By: Claude Sonnet 5.5 --- test/helpers/roma.ts | 29 ++ test/integration/mvp-badge.test.ts | 26 +- test/integration/permessi-allenatore.test.ts | 29 +- test/integration/permessi.test.ts | 375 ++++++++++++++++++- test/integration/s-tiebreak-badge.test.ts | 8 +- test/unit/mvp-voti.test.ts | 78 +++- 6 files changed, 509 insertions(+), 36 deletions(-) create mode 100644 test/helpers/roma.ts diff --git a/test/helpers/roma.ts b/test/helpers/roma.ts new file mode 100644 index 0000000..9ee1af3 --- /dev/null +++ b/test/helpers/roma.ts @@ -0,0 +1,29 @@ +/** + * 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 = 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; +} diff --git a/test/integration/mvp-badge.test.ts b/test/integration/mvp-badge.test.ts index 65c330e..4a72a97 100644 --- a/test/integration/mvp-badge.test.ts +++ b/test/integration/mvp-badge.test.ts @@ -14,7 +14,7 @@ */ import assert from "node:assert/strict"; import { badgeDefs, statoBadge } from "@/lib/badges"; -import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; +import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } 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 { + async function leggiVoti(): Promise { const res = await rest( - `mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, + `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`, ); - return (await res.json()) as VotoMvp[]; + return (await res.json()) as VotoMvpPubblico[]; } /** 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), pareggia in - // m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo, + // 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, // non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge. await upsert({ match_id: `${PREFISSO}-m1`, @@ -107,17 +107,15 @@ 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, pareggia in m3 (non conta)"); - assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente"); + 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"); const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0)); assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta"); diff --git a/test/integration/permessi-allenatore.test.ts b/test/integration/permessi-allenatore.test.ts index 4f12c69..654b0d1 100644 --- a/test/integration/permessi-allenatore.test.ts +++ b/test/integration/permessi-allenatore.test.ts @@ -8,6 +8,7 @@ * 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"; @@ -259,8 +260,34 @@ 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", - body: JSON.stringify({ convocati: [] }), + // M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo. + body: JSON.stringify({ convocati: [], ...romaTra(-3) }), }); + // 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({ diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 4a46dbd..a2cf021 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -16,6 +16,7 @@ */ 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(); @@ -310,12 +311,47 @@ 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, + tipo = "partita", + ): Promise { + 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: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ - match_id: EVENTO, + match_id: EVENTO_MVP, votante_id: "g1", votato_id: "g5", votato_nome: "Cinque", @@ -323,11 +359,55 @@ 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: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ - match_id: EVENTO, + match_id: EVENTO_MVP, votante_id: "g5", votato_id: "g1", votato_nome: "Uno", @@ -336,6 +416,265 @@ 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[]; + 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 { + 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"), "x"].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", @@ -455,8 +794,8 @@ if (!locale) { id: EVENTO_CON_G1, tipo: "partita", titolo: "Partita con g1 convocato, g2 no", - data: "2026-01-02", - ora: "20:00", + data: romaTra(-3).data, + ora: romaTra(-3).ora, luogo: "Palestra", convocati: ["g1", "g5"], }), @@ -482,9 +821,17 @@ 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: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ match_id: EVENTO_CON_G1, votante_id: "g1", @@ -503,8 +850,8 @@ if (!locale) { id: EVENTO_CHIUSO, tipo: "partita", titolo: "Partita con pagelle chiuse", - data: "2026-01-03", - ora: "20:00", + data: romaTra(-3).data, + ora: romaTra(-3).ora, luogo: "Palestra", pagelle_chiuse: true, }), @@ -529,9 +876,17 @@ 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: "resolution=merge-duplicates,return=representation" }, + headers: { Prefer: "return=representation" }, body: JSON.stringify({ match_id: EVENTO_CHIUSO, votante_id: "g1", diff --git a/test/integration/s-tiebreak-badge.test.ts b/test/integration/s-tiebreak-badge.test.ts index af9986d..e004b9c 100644 --- a/test/integration/s-tiebreak-badge.test.ts +++ b/test/integration/s-tiebreak-badge.test.ts @@ -20,7 +20,7 @@ */ import assert from "node:assert/strict"; import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges"; -import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti"; +import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } 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 { + async function leggiMvp(): Promise { const res = await rest( - `mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`, + `mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`, ); - return (await res.json()) as VotoMvp[]; + return (await res.json()) as VotoMvpPubblico[]; } async function leggiPagelle(): Promise { diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts index 2cadc97..de0093f 100644 --- a/test/unit/mvp-voti.test.ts +++ b/test/unit/mvp-voti.test.ts @@ -1,11 +1,18 @@ /** 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"; @@ -44,9 +51,13 @@ assert.deepEqual( ["Anna", "Zeno"], ); -// --- vincitoriMvp: la parità non assegna nessun MVP -------------------------- +// --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito -------------------------- assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" }); -assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato"); +assert.deepEqual( + vincitoriMvp(pari), + { m3: "Anna e Zeno" }, + "due voti pari in testa: sono MVP entrambi, a pari merito", +); assert.deepEqual(vincitoriMvp([]), {}); // --- vincitoriMvp: quorum minimo di voti ------------------------------------- @@ -69,13 +80,38 @@ const triplaPari = [ v("m6", "g2", "g8", "Anna"), v("m6", "g3", "g7", "Bea"), ]; -assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie"); +assert.deepEqual( + vincitoriMvp(triplaPari), + { m6: "Anna, Bea e Zeno" }, + "tre pari in testa: tre MVP, in ordine alfabetico", +); assert.deepEqual( mvpVintiPerGiocatore(triplaPari), - {}, - "stessa parità: nessuna vittoria netta da contare", + { g9: 1, g8: 1, g7: 1 }, + "ognuno dei pari merito conta un titolo", ); +// 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"); @@ -89,8 +125,8 @@ assert.deepEqual( ); assert.deepEqual( mvpVintiPerGiocatore([...partita, ...pari]), - { g2: 1, g5: 1 }, - "la parità non assegna", + { g2: 1, g5: 1, g9: 1, g8: 1 }, + "m3 è pari: i due pari merito prendono un titolo ciascuno", ); assert.deepEqual( mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]), @@ -114,4 +150,32 @@ 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"); From 9d047a6ecdddd3a48d4732c18b3d00ac6b2e30b4 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 26 Oct 2026 09:57:35 +0100 Subject: [PATCH 07/14] Documentazione: DD-051 e DD-052, modulo MVP, database e changelog Voto MVP definitivo, a pari merito, segreto e a tempo; nome del votato dalla rosa. PROJECT_STATE segna che m35-m38 restano da applicare su Supabase (db push rinviato) e il lavoro su feature/gara. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 36 +++++++++++++++---------- docs/CHANGELOG.md | 23 ++++++++++++++++ docs/DATABASE.md | 40 ++++++++++++++-------------- docs/decisions/DD-051.md | 45 +++++++++++++++++++++++++++++++ docs/decisions/DD-052.md | 44 +++++++++++++++++++++++++++++++ docs/decisions/README.md | 2 ++ docs/modules/mvp.md | 57 ++++++++++++++++++++++++++-------------- 7 files changed, 194 insertions(+), 53 deletions(-) create mode 100644 docs/decisions/DD-051.md create mode 100644 docs/decisions/DD-052.md diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index d61a322..4464f28 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 06/10/2026 +Ultimo aggiornamento: 26/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,27 +9,35 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in ## Stato generale -- **Versione:** 1.4.2 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — 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). +- **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). - **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio Project Ref `hetycilxgkdmccelwerq` non si usano più. -- **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. +- **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. - **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:** niente di assegnato. Le voci aperte stanno in - [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». +- **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». ## Cose da sapere diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index b2ada9e..8bc2876 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,29 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] +Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**. + +### 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ù. + +### Sicurezza + +- 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. + ## [1.4.2] - 2026-10-26 Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 78ed8a5..6d50370 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -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 | -| `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. 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) | 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`. | -| `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`. | +| 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`). | ## Turni e notifiche diff --git a/docs/decisions/DD-051.md b/docs/decisions/DD-051.md new file mode 100644 index 0000000..795a49a --- /dev/null +++ b/docs/decisions/DD-051.md @@ -0,0 +1,45 @@ +# 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. diff --git a/docs/decisions/DD-052.md b/docs/decisions/DD-052.md new file mode 100644 index 0000000..c9f251a --- /dev/null +++ b/docs/decisions/DD-052.md @@ -0,0 +1,44 @@ +# 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). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f78a0f8..c7db000 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -87,6 +87,8 @@ 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 | diff --git a/docs/modules/mvp.md b/docs/modules/mvp.md index 60b7284..00e43fd 100644 --- a/docs/modules/mvp.md +++ b/docs/modules/mvp.md @@ -2,6 +2,7 @@ **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` --- @@ -15,7 +16,10 @@ calcolato a runtime. ## Dati Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per -partita, sovrascrivibile. +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. `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 @@ -27,44 +31,59 @@ 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à. Nessun legame con il risultato caricato. + 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. - 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. +- `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. - 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. -- `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 +- `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 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 "vinta" con margine netto; il - risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile +- `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 nel profilo e in home. --- ## Limiti noti -- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. +- 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. - 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): 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. + = 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. --- ## Evoluzioni possibili -- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita). +- Nascondere i risultati finché la votazione non è chiusa. +- Una schermata per gli admin per correggere un voto (oggi solo dal database). From 51b7a10c995761dcf679e94d3e3ac965e5f7ea6f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 2 Nov 2026 08:16:39 +0100 Subject: [PATCH 08/14] =?UTF-8?q?Scout=20live:=20valutazioni=20=E2=88=92?= =?UTF-8?q?=20/=20+=20per=20fondamentale=20e=20per=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ogni convocato un − o un + su battuta, ricezione, attacco e muro, separato per set e fuori dal punteggio (DD-053, migration m39: colonna scout_partite.valutazioni). Lo stato in corso le salva in scout_live.stato; il CSV admin ha la tabella per set. Nella pagina dello scout tolti il blocco avversario/campo, i tasti delle azioni e il tabellone del punteggio; il titolo della sezione mostra cognome e numero del giocatore. Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 8 +- docs/DATABASE.md | 2 +- docs/decisions/DD-053.md | 33 +++ docs/decisions/README.md | 1 + docs/modules/scout-live.md | 28 ++- src/integrations/supabase/types.ts | 3 + src/lib/rosa.ts | 3 +- src/lib/scout-export.ts | 34 +++- src/lib/scout-stato.ts | 5 +- src/lib/scout-store.ts | 42 +++- src/routes/scout.tsx | 190 ++++++++---------- .../20261102120000_m39_scout_valutazioni.sql | 10 + test/integration/scritture.test.ts | 27 +++ test/unit/scout-export.test.ts | 40 ++++ test/unit/scout-stato.test.ts | 6 +- test/unit/scout-store.test.ts | 55 +++++ 16 files changed, 367 insertions(+), 120 deletions(-) create mode 100644 docs/decisions/DD-053.md create mode 100644 supabase/migrations/20261102120000_m39_scout_valutazioni.sql diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c37cf94..5e4db1e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,13 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] -Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**. +Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**. + +### Aggiunto + +- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro, + separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il + CSV admin della partita include una tabella con i conteggi per set. ### Modificato diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 6d50370..1971f2b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -88,7 +88,7 @@ Colonne di `giocatori_squadra` che meritano una nota: | ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. | | `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. | -| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. | +| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. | ## Votazioni diff --git a/docs/decisions/DD-053.md b/docs/decisions/DD-053.md new file mode 100644 index 0000000..a93710d --- /dev/null +++ b/docs/decisions/DD-053.md @@ -0,0 +1,33 @@ +# DD-053 — Valutazioni − / + dei fondamentali nello Scout Live + +**Data:** 2 novembre 2026 +**Stato:** Accettata (migration `m39`) + +**Contesto** +Lo scout registrava solo ciò che fa punto o lo toglie (attacco, ace, muro, errore). Mancava un +giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non cambia il punteggio. + +**Decisione** + +- Per ogni convocato, un **−** o un **+** su battuta, ricezione, attacco e muro, **per set**. +- Sono valutazioni, non azioni: lista `valutazioni` separata da `azioni`, così non entrano nel + punteggio, nei parziali né nei totali P/A/M/E (`totaliPerGiocatore`, badge, classifiche: DD-008). +- Ogni tocco è una riga (giocatore, fondamentale, segno, set, istante): permette l'annulla e il + conteggio per set senza strutture da tenere allineate. +- Stato in corso dentro `scout_live.stato` (jsonb, nessuna modifica); partita archiviata nella + nuova colonna `scout_partite.valutazioni jsonb NOT NULL DEFAULT '[]'` (`m39`, solo additiva). + +**Alternative scartate** + +- Nuovi `AzioneTipo` dentro `azioni` → ogni consumatore (punteggio, totali, CSV) andrebbe filtrato + per non contarli come punti. +- Solo contatori aggregati per giocatore/set → niente annulla preciso, né ordine cronologico. +- Tabella dedicata → per un dato che nasce e muore con la partita archiviata è più schema del + necessario. + +**Conseguenze** +Le partite già archiviate hanno `valutazioni = []`. Le valutazioni sono visibili solo nel CSV +admin; sono dato di squadra a uso tecnico, non entrano in classifiche individuali. + +**Riesame** +Se servirà confrontare le valutazioni tra partite o mostrarle ai giocatori. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c7db000..be064a7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -89,6 +89,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand | [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-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live | | [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 | diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index 32c3950..0057799 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -10,7 +10,8 @@ ## Obiettivo Permettere a un solo referente per volta di registrare in tempo reale, durante la partita, -punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su +punti, ace, muri ed errori di ciascun giocatore in campo — più un giudizio − / + sui +fondamentali, per set — con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato aggiornato da qualunque dispositivo. @@ -21,7 +22,8 @@ aggiornato da qualunque dispositivo. - `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per `evento_id`, quindi un solo detentore). - `scout_live` — stato in corso (azioni non ancora concluse) di una sessione. -- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni), +- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni, + valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero). - `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento (`UNIQUE evento_id, giocatore_id`). @@ -74,6 +76,21 @@ risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni c --- +## Valutazioni dei fondamentali (− / +) + +Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro +([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai +totali P/A/M/E. Si seleziona il giocatore (passo 1) e si tocca − o + sul fondamentale (passo 3); +accanto a ogni tasto compare il conteggio di quel giocatore **nel set in corso**. «Annulla» nella +sezione toglie l'ultima valutazione del set in corso (l'«Annulla» in alto resta per le azioni). + +Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una +lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()` +(`scout-store.ts`). Lo stato in corso le salva in `scout_live.stato.valutazioni` (assente negli +stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_partite.valutazioni`. + +--- + ## Fine partita `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in @@ -85,8 +102,10 @@ e rilascia la sessione. ## Export CSV `scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per -giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita, -sezione "Report tecnico". +giocatore e log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con i + +- e i − di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla pagina partita, + sezione "Report tecnico". --- @@ -127,4 +146,5 @@ calcolati a runtime). ## Evoluzioni possibili +- Mostrare le valutazioni anche nella pagina della partita (oggi solo nel CSV admin). - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 3205698..f272d6f 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -536,6 +536,7 @@ export type Database = { parziali: Json; set_loro: number; set_nostri: number; + valutazioni: Json; }; Insert: { avversario: string; @@ -548,6 +549,7 @@ export type Database = { parziali?: Json; set_loro: number; set_nostri: number; + valutazioni?: Json; }; Update: { avversario?: string; @@ -560,6 +562,7 @@ export type Database = { parziali?: Json; set_loro?: number; set_nostri?: number; + valutazioni?: Json; }; Relationships: []; }; diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 62d4f5d..9fbb25b 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string { export function useAnagraficaRosa({ conAllenatori = false, }: { conAllenatori?: boolean } = {}): Array< - Pick + Pick & { cognome: string } > { const { righe: squadra } = useGiocatoriSquadra(); return useMemo( @@ -44,6 +44,7 @@ export function useAnagraficaRosa({ .map((g) => ({ id: g.id, nome: nomeCompleto(g), + cognome: g.cognome, ruolo: g.ruolo, numero: g.numero, nascita: g.nascita ?? "", diff --git a/src/lib/scout-export.ts b/src/lib/scout-export.ts index bafe5cc..d0d7dc5 100644 --- a/src/lib/scout-export.ts +++ b/src/lib/scout-export.ts @@ -1,5 +1,11 @@ import type { Giocatore } from "./crapp-data"; -import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; +import { + azioniMeta, + contaValutazioni, + fondamentali, + totaliPerGiocatore, + type ScoutMatch, +} from "./scout-store"; /** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ export function rigaCsv(campi: Array) { @@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string { if (!t) continue; righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); } + if (match.valutazioni.length > 0) { + righe.push(""); + righe.push( + rigaCsv([ + "Set", + "Numero", + "Giocatore", + ...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]), + ]), + ); + const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y); + for (const set of sets) { + for (const g of rosa) { + if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue; + const c = contaValutazioni(match.valutazioni, g.id, set); + righe.push( + rigaCsv([ + set, + g.numero, + g.nome, + ...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]), + ]), + ); + } + } + } righe.push(""); righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); for (const a of match.azioni) { diff --git a/src/lib/scout-stato.ts b/src/lib/scout-stato.ts index 10d568e..785ec61 100644 --- a/src/lib/scout-stato.ts +++ b/src/lib/scout-stato.ts @@ -1,10 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import type { Azione } from "./scout-store"; +import type { Azione, Valutazione } from "./scout-store"; /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */ export type StatoScout = { azioni: Azione[]; + /** Assente negli stati salvati prima dell'introduzione delle valutazioni. */ + valutazioni?: Valutazione[]; setChiusi: Array<[number, number]>; avversario: string; casa: boolean; @@ -12,6 +14,7 @@ export type StatoScout = { export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({ azioni: [], + valutazioni: [], setChiusi: [], avversario, casa, diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index b89638f..d54087c 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -60,6 +60,42 @@ export type Azione = { ts: number; }; +/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */ +export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro"; + +export const fondamentali: Array<{ id: Fondamentale; label: string }> = [ + { id: "battuta", label: "Battuta" }, + { id: "ricezione", label: "Ricezione" }, + { id: "attacco", label: "Attacco" }, + { id: "muro", label: "Muro" }, +]; + +export type Valutazione = { + id: string; + giocatoreId: string; + fondamentale: Fondamentale; + segno: "+" | "-"; + set: number; + ts: number; +}; + +export type ConteggioValutazioni = Record; + +/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */ +export function contaValutazioni( + valutazioni: Valutazione[], + giocatoreId: string, + set?: number, +): ConteggioValutazioni { + const out = {} as ConteggioValutazioni; + for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 }; + for (const v of valutazioni) { + if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue; + out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1; + } + return out; +} + export type ScoutMatch = { id: string; data: string; @@ -69,6 +105,7 @@ export type ScoutMatch = { setLoro: number; parziali: Array<[number, number]>; azioni: Azione[]; + valutazioni: Valutazione[]; }; type RigaScoutPartita = { @@ -80,6 +117,7 @@ type RigaScoutPartita = { set_loro: number; parziali: unknown; azioni: unknown; + valutazioni: unknown; }; function daRiga(r: RigaScoutPartita): ScoutMatch { @@ -92,6 +130,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch { setLoro: r.set_loro, parziali: (r.parziali as Array<[number, number]> | null) ?? [], azioni: (r.azioni as Azione[] | null) ?? [], + valutazioni: (r.valutazioni as Valutazione[] | null) ?? [], }; } @@ -100,7 +139,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const; async function fetchScoutMatches(): Promise { const { data, error } = await supabase .from("scout_partite") - .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") + .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni") .order("creato_il", { ascending: false }); if (error) throw error; return (data ?? []).map(daRiga); @@ -131,6 +170,7 @@ export function useSalvaScoutMatch() { set_loro: input.match.setLoro, parziali: JSON.parse(JSON.stringify(input.match.parziali)), azioni: JSON.parse(JSON.stringify(input.match.azioni)), + valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)), }); if (error) throw error; return input.match; diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 36f9711..3e1c17b 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -3,7 +3,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { Card } from "@/components/crapp/ui-bits"; import { formatData } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { useAnagraficaRosa } from "@/lib/rosa"; @@ -27,10 +26,14 @@ import { } from "@/lib/scout-live"; import { azioniMeta, + contaValutazioni, + fondamentali, useSalvaScoutMatch, totaliPerGiocatore, type Azione, type AzioneTipo, + type Fondamentale, + type Valutazione, } from "@/lib/scout-store"; export const Route = createFileRoute("/scout")({ @@ -52,8 +55,6 @@ export const Route = createFileRoute("/scout")({ component: Scout, }); -const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"]; - function Blocco({ icona, titolo, @@ -196,10 +197,11 @@ function ScoutBoard({ .trim() || "Avversario", partita.titolo.trim().toLowerCase().startsWith("crap"), ); - const [avversario, setAvversario] = useState(base.avversario); - const [casa, setCasa] = useState(base.casa); + const [avversario] = useState(base.avversario); + const [casa] = useState(base.casa); const [setChiusi, setSetChiusi] = useState>(base.setChiusi); const [azioni, setAzioni] = useState(base.azioni); + const [valutazioni, setValutazioni] = useState(base.valutazioni ?? []); const [selezionato, setSelezionato] = useState(null); const salva = useSalvaStatoScout(); const cancella = useCancellaStatoScout(); @@ -223,37 +225,44 @@ function ScoutBoard({ const id = window.setTimeout(() => { void salva.mutateAsync({ eventoId: partita.id, - stato: { azioni, setChiusi, avversario, casa }, + stato: { azioni, valutazioni, setChiusi, avversario, casa }, }); }, 800); return () => window.clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [azioni, setChiusi, avversario, casa, partita.id]); + }, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]); const setCorrente = setChiusi.length + 1; const azioniSet = azioni.filter((a) => a.set === setCorrente); const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiLoro = azioniSet.length - puntiNoi; - const setNostri = setChiusi.filter(([n, l]) => n > l).length; - const setLoro = setChiusi.length - setNostri; const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]); - function registra(tipo: AzioneTipo) { - const meta = azioniMeta[tipo]; - if (meta.richiedeGiocatore && !selezionato) { + function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) { + if (!selezionato) { toast.error("Seleziona prima il giocatore"); return; } - setAzioni((prev) => { - const nuova: Azione = { + setValutazioni((prev) => [ + ...prev, + { id: `${Date.now()}-${prev.length}`, - tipo, + giocatoreId: selezionato, + fondamentale, + segno, set: setCorrente, ts: Date.now(), - ...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}), - }; - return [...prev, nuova]; - }); + }, + ]); + } + + function annullaValutazione() { + const ultima = valutazioni[valutazioni.length - 1]; + if (!ultima || ultima.set !== setCorrente) { + toast.error("Puoi annullare solo le valutazioni del set in corso"); + return; + } + setValutazioni((prev) => prev.slice(0, -1)); } function annulla() { @@ -294,6 +303,7 @@ function ScoutBoard({ setLoro: parziali.length - vinti, parziali, azioni, + valutazioni, }, }); } catch { @@ -307,6 +317,10 @@ function ScoutBoard({ navigate({ to: "/squadra" }); } + const giocatoreSel = rosa.find((g) => g.id === selezionato); + const contiSelezionato = selezionato + ? contaValutazioni(valutazioni, selezionato, setCorrente) + : null; const ultime = [...azioni].slice(-4).reverse(); return ( @@ -334,35 +348,6 @@ function ScoutBoard({
- -
-
-

CRAP Volley

-

{puntiNoi}

-
-

- {setNostri}-{setLoro} -

-
-

- {avversario || "Avversario"} -

-

{puntiLoro}

-
-
- - {setChiusi.length > 0 ? ( -
- {setChiusi.map((p, i) => ( - - {p[0]}-{p[1]} - - ))} -
- ) : null}
@@ -400,39 +385,55 @@ function ScoutBoard({
-

- 2. Tocca l'azione -

-
- {ordineAzioni.map((tipo) => ( - - ))} +
+

+ 2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "} + {setCorrente} +

+
-
- {(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => ( - - ))} +
+ {!selezionato ? ( +

+ Tocca un giocatore: i + e i − non cambiano il punteggio. +

+ ) : null} + {fondamentali.map((f) => { + const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 }; + return ( +
+ {f.label} + + +
+ ); + })}
@@ -472,35 +473,6 @@ function ScoutBoard({ -
- - - setAvversario(e.target.value)} - className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]" - /> -
- {[true, false].map((v) => ( - - ))} -
-
-
-
); diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts index 747bab8..e0a0dd5 100644 --- a/test/unit/scout-store.test.ts +++ b/test/unit/scout-store.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { giocatori } from "@/lib/crapp-data"; import { azioniMeta, + contaPiuSenzaRicezione, contaValutazioni, fondamentali, giocatoriConScout, @@ -139,3 +140,21 @@ assert.deepEqual( "giocatore senza valutazioni", ); assert.equal(totaliPerGiocatore([]).size, 0, "le valutazioni non sono azioni: nessun punto"); + +// --- + del riquadro giocatore: tutti i fondamentali tranne la ricezione, nel set ------- +const piuVal = [ + v("g1", "battuta", "+"), + v("g1", "attacco", "+"), + v("g1", "muro", "+"), + v("g1", "ricezione", "+"), + v("g1", "attacco", "-"), + v("g1", "battuta", "+", 2), + v("g2", "attacco", "+"), +]; +assert.equal( + contaPiuSenzaRicezione(piuVal, "g1", 1), + 3, + "niente ricezione, niente −, solo il set 1", +); +assert.equal(contaPiuSenzaRicezione(piuVal, "g1", 2), 1); +assert.equal(contaPiuSenzaRicezione(piuVal, "g3", 1), 0); From 74c78faac580238e64d1366ea61c78bb4d778256 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 2 Nov 2026 08:24:43 +0100 Subject: [PATCH 11/14] Documentazione: ordinamento della selezione giocatore e conteggio dei + Co-Authored-By: Claude Sonnet 5.5 --- docs/CHANGELOG.md | 2 ++ docs/modules/scout-live.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7713d7b..27635ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,8 @@ Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**. - Scout live: tolti i tasti dei punti, il tabellone del punteggio e il blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set ([DD-053](decisions/DD-053.md)). Per ora «Chiudi set» e «Fine partita» non salvano senza punti. +- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di + ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti. - 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 diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index bfd03ec..c6c4963 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -66,7 +66,7 @@ perché occupava spazio 6 giorni su 7. ## Cosa registra -La schermata `/scout` ha due passi: **1. Tocca il giocatore** (griglia dei convocati, filtrati sulle +La schermata `/scout` ha due passi: **Selezione giocatore** (griglia dei convocati, ordinata per cognome o per numero con il selettore a destra del titolo, default cognome; in ogni riquadro, sotto il nome, i **+** del giocatore nel set in corso, ricezione esclusa; filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha risposto) e **2. Valuta** (vedi sotto), seguiti da «Ultime azioni», «Chiudi set» e «Fine partita». Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento. @@ -85,7 +85,7 @@ leggono le partite scoutate prima di questa modifica, ma oggi nessuna schermata Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro ([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai -totali P/A/M/E. Si seleziona il giocatore (passo 1) e si tocca − o + sul fondamentale (passo 2, il cui titolo mostra +totali P/A/M/E. Si seleziona il giocatore (primo passo) e si tocca − o + sul fondamentale (passo 2, il cui titolo mostra cognome e numero del giocatore e il set in corso); accanto a ogni tasto compare il conteggio di quel giocatore **nel set in corso**. «Annulla» nella sezione toglie l'ultima valutazione del set in corso (l'«Annulla» in alto agisce solo sulle azioni, quindi oggi è sempre spento). From d86b38d47bda6e7cafa088d74ce7841976c93063 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 2 Nov 2026 08:36:51 +0100 Subject: [PATCH 12/14] Scout live: salvataggio immediato e chiusura di set e partita senza punti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lo stato in corso si salva a ogni tocco (in coda, con avviso se fallisce). «Chiudi set» e «Fine partita» funzionano con le sole valutazioni; le partite senza punteggio (haPunteggio) non compaiono come risultato in Home, Classifica e pagina partita. Il pulsante di ripresa riconosce valutazioni e set chiusi. Co-Authored-By: Claude Sonnet 5.5 --- src/lib/scout-store.ts | 21 ++++++++++++++++++ src/routes/classifica.tsx | 4 ++-- src/routes/index.tsx | 6 +++-- src/routes/partita.$id.tsx | 41 ++++++++++++++++++----------------- src/routes/scout.tsx | 38 ++++++++++++++++++++++---------- test/unit/scout-store.test.ts | 39 +++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 36 deletions(-) diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index ffc0a9d..3eb5114 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -114,6 +114,18 @@ export function contaPiuSenzaRicezione( ).length; } +/** + * Parziali da archiviare: i set chiusi più il set in corso, se ha qualcosa dentro (punti o + * valutazioni). Senza punteggio un set vale 0-0: serve solo a numerarlo. + */ +export function parzialiFinali( + setChiusi: Array<[number, number]>, + corrente: [number, number], + haDati: boolean, +): Array<[number, number]> { + return haDati ? [...setChiusi, corrente] : setChiusi; +} + export type ScoutMatch = { id: string; data: string; @@ -217,6 +229,15 @@ export function useEliminaScoutMatch() { }); } +/** + * Una partita scoutata ha un risultato solo se ha registrato punti: quelle fatte di sole + * valutazioni − / + hanno parziali 0-0 e non devono sostituire il referto ufficiale né + * comparire come risultato in Home e Classifica. + */ +export function haPunteggio(match: Pick): boolean { + return match.parziali.some(([n, l]) => n + l > 0); +} + /** Somma delle azioni di un match per giocatore. */ export function totaliPerGiocatore(azioni: Azione[]) { const out = new Map(); diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index a06abed..5e658e2 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { formatData, type RigaClassifica } from "@/lib/crapp-data"; import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; -import { useScoutMatches } from "@/lib/scout-store"; +import { haPunteggio, useScoutMatches } from "@/lib/scout-store"; import { useCsi } from "@/lib/csi"; import { isNostraSquadra, @@ -150,7 +150,7 @@ function Classifica() { const m = matchDaPartitaCsi(p); return { ...m, mvp: mvpPerData(m.data), scout: false }; }) - : scoutMatches.map((m) => ({ + : scoutMatches.filter(haPunteggio).map((m) => ({ id: m.id, data: m.data, avversario: m.avversario, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1838ff0..66b05ed 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -19,7 +19,7 @@ import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra"; import { useGiocatoreBase } from "@/lib/user-store"; import { useCsi } from "@/lib/csi"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; -import { useScoutMatches } from "@/lib/scout-store"; +import { haPunteggio, useScoutMatches } from "@/lib/scout-store"; import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti"; import { mediaVotoColpoDOcchio } from "@/lib/pagelle"; @@ -63,7 +63,9 @@ function Index() { const votiMvp = useVotiMvp(); const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []); const csiGiocate = csi ? partiteGiocate(csi.partite) : []; - const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null); + const ultimaBase = csiGiocate[0] + ? matchDaPartitaCsi(csiGiocate[0]) + : (scoutMatches.find(haPunteggio) ?? null); const obiettivi = useObiettivi(); const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null; const eventoUltima = ultimaBase diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 64dcdf0..7b84561 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -11,7 +11,7 @@ import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/cr import { Pagelle } from "@/components/crapp/Pagelle"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { ScoutEntry } from "@/components/crapp/ScoutEntry"; -import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; +import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { useIsAdmin } from "@/lib/ruoli"; import { useSonoAllenatore } from "@/lib/user-store"; @@ -70,25 +70,26 @@ function PartitaDetail() { const csiMatch = csi ? partiteGiocate(csi.partite).find((p) => p.data === evento.data) : undefined; - const match = scout - ? { - id: scout.id, - data: scout.data, - avversario: scout.avversario, - logoAvversario: "", - casa: scout.casa, - setNostri: scout.setNostri, - setLoro: scout.setLoro, - parziali: scout.parziali, - campo: "", - girone: "", - numeroGara: "", - arbitro: "", - link: "", - } - : csiMatch - ? matchDaPartitaCsi(csiMatch) - : undefined; + const match = + scout && haPunteggio(scout) + ? { + id: scout.id, + data: scout.data, + avversario: scout.avversario, + logoAvversario: "", + casa: scout.casa, + setNostri: scout.setNostri, + setLoro: scout.setLoro, + parziali: scout.parziali, + campo: "", + girone: "", + numeroGara: "", + arbitro: "", + link: "", + } + : csiMatch + ? matchDaPartitaCsi(csiMatch) + : undefined; const totaliTeam = scout ? totaliSquadra([scout]) : null; const avversario = evento.titolo.includes(" vs ") ? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo) diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index d32a464..33420a4 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -29,6 +29,7 @@ import { azioniMeta, contaPiuSenzaRicezione, contaValutazioni, + parzialiFinali, fondamentali, useSalvaScoutMatch, type Azione, @@ -121,7 +122,11 @@ function Scout() { const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null; const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id; - const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0; + const ripresa = + !!statoSalvato.data && + (statoSalvato.data.azioni.length > 0 || + (statoSalvato.data.valutazioni?.length ?? 0) > 0 || + statoSalvato.data.setChiusi.length > 0); if (!controllo) { return ( @@ -212,6 +217,7 @@ function ScoutBoard({ // Solo id/nome/numero servono (selezione e display): niente statistiche di squadra. const rosa = useAnagraficaRosa(); const finito = useRef(false); + const codaSalvataggio = useRef>(Promise.resolve()); /** In campo solo chi ha confermato la presenza (anche in ritardo). */ const convocati = useMemo(() => { @@ -229,13 +235,21 @@ function ScoutBoard({ // Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui. useEffect(() => { if (finito.current) return; - const id = window.setTimeout(() => { - void salva.mutateAsync({ - eventoId: partita.id, - stato: { azioni, valutazioni, setChiusi, avversario, casa }, + // Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si + // chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito + // rimette a posto tutto; la coda li tiene in ordine. + const stato = { azioni, valutazioni, setChiusi, avversario, casa }; + codaSalvataggio.current = codaSalvataggio.current + .then(() => salva.mutateAsync({ eventoId: partita.id, stato })) + .then(() => { + toast.dismiss("scout-salvataggio"); + }) + .catch(() => { + toast.error("Salvataggio non riuscito: controlla la connessione", { + id: "scout-salvataggio", + duration: Infinity, + }); }); - }, 800); - return () => window.clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]); @@ -243,6 +257,7 @@ function ScoutBoard({ const azioniSet = azioni.filter((a) => a.set === setCorrente); const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiLoro = azioniSet.length - puntiNoi; + const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente); function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) { if (!selezionato) { @@ -281,17 +296,16 @@ function ScoutBoard({ } function chiudiSet() { - if (puntiNoi === 0 && puntiLoro === 0) { - toast.error("Set senza azioni"); + if (!setHaDati) { + toast.error("Set senza valutazioni"); return; } setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]); - toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`); + toast.success(`Set ${setCorrente} chiuso`); } async function finePartita() { - const parziali: Array<[number, number]> = - puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi; + const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati); if (parziali.length === 0) { toast.error("Nessun set da salvare"); return; diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts index e0a0dd5..018a694 100644 --- a/test/unit/scout-store.test.ts +++ b/test/unit/scout-store.test.ts @@ -4,6 +4,8 @@ import { giocatori } from "@/lib/crapp-data"; import { azioniMeta, contaPiuSenzaRicezione, + haPunteggio, + parzialiFinali, contaValutazioni, fondamentali, giocatoriConScout, @@ -158,3 +160,40 @@ assert.equal( ); assert.equal(contaPiuSenzaRicezione(piuVal, "g1", 2), 1); assert.equal(contaPiuSenzaRicezione(piuVal, "g3", 1), 0); + +// --- parziali finali: il set in corso conta solo se ha dati ------------------------ +assert.deepEqual( + parzialiFinali([[25, 20]], [0, 0], true), + [ + [25, 20], + [0, 0], + ], + "set con sole valutazioni", +); +assert.deepEqual( + parzialiFinali([[25, 20]], [0, 0], false), + [[25, 20]], + "set vuoto: non si archivia", +); +assert.deepEqual(parzialiFinali([], [0, 0], false), [], "nessun set da salvare"); + +// --- haPunteggio: le partite di sole valutazioni non sono un risultato ------------- +assert.equal( + haPunteggio({ + parziali: [ + [0, 0], + [0, 0], + ], + }), + false, +); +assert.equal(haPunteggio({ parziali: [] }), false); +assert.equal( + haPunteggio({ + parziali: [ + [0, 0], + [25, 18], + ], + }), + true, +); From ebafb98b32f54ec9cdfe6540682bb4950bac8151 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 2 Nov 2026 08:36:51 +0100 Subject: [PATCH 13/14] Documentazione: salvataggio immediato e partite scoutate senza punteggio Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 2 +- docs/CHANGELOG.md | 5 ++++- docs/decisions/DD-053.md | 6 ++++-- docs/modules/scout-live.md | 22 +++++++++++++++------- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 0ee00bc..88df612 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -38,7 +38,7 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in - **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). Poi lo scout live ridotto alle valutazioni − / + - per set (DD-053): da completare la chiusura di set e partita senza punti. Non ancora in `main`, non rilasciato, versione + per set (DD-053), salvate subito su database. Migration `m39` da applicare in produzione. Non ancora in `main`, non rilasciato, versione invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». ## Cose da sapere diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27635ef..c5c4771 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -25,7 +25,10 @@ Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**. - Scout live: tolti i tasti dei punti, il tabellone del punteggio e il blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set - ([DD-053](decisions/DD-053.md)). Per ora «Chiudi set» e «Fine partita» non salvano senza punti. + ([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le + partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita. +- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e, + se non riesce, compare un avviso. - Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti. - Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si diff --git a/docs/decisions/DD-053.md b/docs/decisions/DD-053.md index 897f06d..a4c6526 100644 --- a/docs/decisions/DD-053.md +++ b/docs/decisions/DD-053.md @@ -19,8 +19,10 @@ giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non c - Nella schermata dello scout, su richiesta, sono stati tolti i tasti delle azioni, il tabellone del punteggio e il blocco avversario/campo: lo scout è fatto solo di valutazioni. Non c'è più una - chiusura di set né di partita che funzioni senza punti (vedi - [modulo Scout Live](../modules/scout-live.md), «Limiti noti»). + «Chiudi set» e «Fine partita» funzionano anche senza punti: il set chiuso vale 0-0 e la partita + archiviata di sole valutazioni **non è un risultato** (`haPunteggio()`), quindi non compare in + Home, Classifica né al posto del referto CSI. +- Il salvataggio dello stato in corso è immediato a ogni tocco, con avviso se fallisce. **Alternative scartate** diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index c6c4963..27fe384 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -69,7 +69,7 @@ perché occupava spazio 6 giorni su 7. La schermata `/scout` ha due passi: **Selezione giocatore** (griglia dei convocati, ordinata per cognome o per numero con il selettore a destra del titolo, default cognome; in ogni riquadro, sotto il nome, i **+** del giocatore nel set in corso, ricezione esclusa; filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha risposto) e **2. Valuta** (vedi sotto), seguiti da «Ultime azioni», «Chiudi set» e «Fine partita». Salvataggio automatico su -`scout_live` con debounce di 800ms a ogni cambiamento. +`scout_live` **a ogni cambiamento**, senza attesa: i salvataggi vanno in coda, uno alla volta e in ordine, e ognuno scrive lo stato intero (uno riuscito rimedia a uno fallito). Se un salvataggio non riesce compare un avviso fisso che sparisce al salvataggio successivo riuscito. **Punteggio e azioni.** L'interfaccia non registra più il punteggio: sono stati tolti i tasti delle azioni (punto attacco, ace, muro, errore nostro, punto e errore avversario), il tabellone con punti @@ -99,9 +99,12 @@ stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_parti ## Fine partita -**Limite attuale:** `chiudiSet()` e `finePartita()` si basano sui punti, che non si registrano più. «Chiudi -set» risponde «Set senza azioni» (quindi il set resta sempre il 1) e «Fine partita» «Nessun set da -salvare»: le valutazioni restano nello stato in corso e non arrivano in `scout_partite`. +Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutazione, e +`finePartita()` archivia anche la partita fatta di sole valutazioni: il set in corso entra nei +parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde +«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**: +`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il +referto CSI; resta visibile il «Report tecnico» con il CSV. `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in `scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato) @@ -144,8 +147,13 @@ calcolati a runtime). ## Limiti noti -- Senza punteggio, set e partita non si chiudono (vedi «Fine partita»): da risolvere, per esempio - chiudendo il set anche senza punti e salvando la partita con le sole valutazioni. +- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita» + entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più. +- Le valutazioni dei set chiusi e delle partite finite sono salvate ma non si rivedono nell'app: lo + scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella + pagina partita. +- Il blocco «Report tecnico» della pagina partita mostra ancora Punti/Ace/Muri/Errori (a zero per le + partite di sole valutazioni) e non le valutazioni, che sono solo nel CSV admin. - Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica garanzia è il lock di sessione (vedi sopra). - Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock: @@ -159,5 +167,5 @@ calcolati a runtime). ## Evoluzioni possibili -- Mostrare le valutazioni anche nella pagina della partita (oggi solo nel CSV admin). +- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin) e, nello scout, in sola lettura quelle dei set chiusi. - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. From 252e1528f64f5f13dab46b9b47a100f307be60ed Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 2 Nov 2026 08:49:57 +0100 Subject: [PATCH 14/14] Messaggio di fine partita, test a inizio stagione e documenti di stato MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il messaggio dopo «Fine partita» dice solo «Partita salvata» (il voto MVP apre 2 ore dopo l'inizio, non dipende dallo scout). I due test che pretendevano una gara CSI già giocata tollerano l'inizio stagione (uno si salta). «Calendario ufficiale» passa in «Fatto» nella roadmap; PROJECT_STATE conta le migration fino alla m39. Co-Authored-By: Claude Sonnet 5.5 --- PROJECT_STATE.md | 14 +++++++------- docs/ROADMAP.md | 4 ++-- src/routes/scout.tsx | 2 +- test/e2e/percorsi.test.ts | 6 ++---- test/integration/api.test.ts | 13 ++++++++----- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 88df612..d8b3c79 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 26/10/2026 +Ultimo aggiornamento: 02/11/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 @@ -21,11 +21,11 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure 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 +- **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione + sono applicate 45 su 50, fino alla `m34_link_maps_evento` (verificato con + `npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m39`, nessuna + migration remota è assente dal repository). **Da applicare ancora: `m35`-`m39`** + (voto MVP e badge social, DD-051 e DD-052, valutazioni dello scout, DD-053; 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. @@ -38,7 +38,7 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in - **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). Poi lo scout live ridotto alle valutazioni − / + - per set (DD-053), salvate subito su database. Migration `m39` da applicare in produzione. Non ancora in `main`, non rilasciato, versione + per set (DD-053), salvate subito su database. Non ancora in `main`, non rilasciato, versione invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». ## Cose da sapere diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 293cdc9..1b2f862 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,6 +34,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Collegamento CSI (stagione 2026/27) - [x] Classifica automatica (campionato e Coppa) - [x] Risultati campionato +- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato: + a inizio stagione il calendario c'è prima dei risultati - [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria calcolata dal CSI - [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza @@ -54,8 +56,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). ## Prossimo -- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina - Campionato usa solo quelle giocate - [ ] Database esercizi - [ ] AI Allenamenti - [ ] Archivio allenamenti diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 33420a4..ae6eb9e 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -330,7 +330,7 @@ function ScoutBoard({ toast.error("Salvataggio non riuscito, riprova"); return; } - toast.success("Partita salvata: ora la squadra può votare l'MVP"); + toast.success("Partita salvata"); finito.current = true; void cancella.mutateAsync(partita.id); onFine(); diff --git a/test/e2e/percorsi.test.ts b/test/e2e/percorsi.test.ts index e41f05c..0feadd5 100644 --- a/test/e2e/percorsi.test.ts +++ b/test/e2e/percorsi.test.ts @@ -62,10 +62,8 @@ try { const csi = (await json(res)) as DatiCsi; const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra)); assert.ok(noi, "la nostra squadra arriva dal portale"); - assert.ok( - csi.partite.some((p) => p.setNostri !== null), - "ci sono risultati giocati", - ); + // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata. + assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale"); const html = await pagina("/classifica"); assert.match(titolo(html), /Classifica campionato/); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 1d2c09f..a916a4f 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -124,9 +124,13 @@ try { assert.ok(Date.now() - t0 < 2_000, "risposta immediata"); }); - await provaCsi( - "GET /api/public/csi-partita/$id restituisce formazioni e precedenti", - async () => { + // A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test + // del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile. + const nomeDettaglio = "GET /api/public/csi-partita/$id restituisce formazioni e precedenti"; + if (csiRaggiungibile && !partiteGiocate(csi?.partite ?? [])[0]) { + salta(nomeDettaglio, "nessuna gara ancora giocata nella stagione"); + } else + await provaCsi(nomeDettaglio, async () => { const primaGiocata = partiteGiocate(csi?.partite ?? [])[0]; assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio"); const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); @@ -147,8 +151,7 @@ try { const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`)); assert.equal(secondo.status, 200); assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata"); - }, - ); + }); await provaCsi( "GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",