diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 151f74a..bef12fc 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -36,8 +36,9 @@ non in questo file. | Tabella | Scopo | Note | | ---------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scout_sessioni` | Sessioni di Scout Live: una sessione corrisponde a una partita. | **Non ancora usata dal codice**: oggi lo stato della sessione vive in `localStorage` (`src/lib/scout-live.ts`, `scout-store.ts`) e sul database finiscono solo le azioni in `scout_live`. | -| `scout_live` | Eventi registrati durante lo Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). | +| `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`. Prima viveva solo in `localStorage`: "Scout occupato da X" non funzionava mai tra dispositivi diversi (fix M7). | +| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione di Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). Letta/scritta da `src/lib/scout-stato.ts`. | +| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. Prima il risultato finale finiva solo in `localStorage`: invisibile a chiunque non fosse il dispositivo di chi aveva chiuso la partita (fix M7). | ## Votazioni diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 76aa6da..45fc063 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -4,7 +4,32 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "14.15"; + PostgrestVersion: "14.5"; + }; + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; }; public: { Tables: { @@ -194,6 +219,45 @@ export type Database = { }; Relationships: []; }; + giocatori_squadra: { + Row: { + aggiornato_il: string; + attivo: boolean; + auth_user_id: string | null; + cognome: string; + creato_il: string; + email: string | null; + id: string; + nome: string; + numero: number; + ruolo: string; + }; + Insert: { + aggiornato_il?: string; + attivo?: boolean; + auth_user_id?: string | null; + cognome: string; + creato_il?: string; + email?: string | null; + id: string; + nome: string; + numero: number; + ruolo: string; + }; + Update: { + aggiornato_il?: string; + attivo?: boolean; + auth_user_id?: string | null; + cognome?: string; + creato_il?: string; + email?: string | null; + id?: string; + nome?: string; + numero?: number; + ruolo?: string; + }; + Relationships: []; + }; mvp_voti: { Row: { created_at: string; @@ -296,6 +360,77 @@ export type Database = { }, ]; }; + profili_giocatore: { + Row: { + aggiornato_il: string; + certificato_path: string | null; + certificato_scadenza: string | null; + creato_il: string; + data_nascita: string | null; + documento_emissione: string | null; + documento_fronte_path: string | null; + documento_numero: string | null; + documento_retro_path: string | null; + documento_rilasciato_da: string | null; + documento_scadenza: string | null; + documento_tipo: string | null; + email: string | null; + foto_path: string | null; + giocatore_id: string; + indirizzo: string | null; + luogo_nascita: string | null; + telefono: string | null; + }; + Insert: { + aggiornato_il?: string; + certificato_path?: string | null; + certificato_scadenza?: string | null; + creato_il?: string; + data_nascita?: string | null; + documento_emissione?: string | null; + documento_fronte_path?: string | null; + documento_numero?: string | null; + documento_retro_path?: string | null; + documento_rilasciato_da?: string | null; + documento_scadenza?: string | null; + documento_tipo?: string | null; + email?: string | null; + foto_path?: string | null; + giocatore_id: string; + indirizzo?: string | null; + luogo_nascita?: string | null; + telefono?: string | null; + }; + Update: { + aggiornato_il?: string; + certificato_path?: string | null; + certificato_scadenza?: string | null; + creato_il?: string; + data_nascita?: string | null; + documento_emissione?: string | null; + documento_fronte_path?: string | null; + documento_numero?: string | null; + documento_retro_path?: string | null; + documento_rilasciato_da?: string | null; + documento_scadenza?: string | null; + documento_tipo?: string | null; + email?: string | null; + foto_path?: string | null; + giocatore_id?: string; + indirizzo?: string | null; + luogo_nascita?: string | null; + telefono?: string | null; + }; + Relationships: [ + { + foreignKeyName: "profili_giocatore_giocatore_id_fkey"; + columns: ["giocatore_id"]; + isOneToOne: true; + referencedRelation: "giocatori_squadra"; + referencedColumns: ["id"]; + }, + ]; + }; promemoria_push: { Row: { creato_il: string; @@ -386,6 +521,45 @@ export type Database = { }; Relationships: []; }; + scout_partite: { + Row: { + avversario: string; + azioni: Json; + casa: boolean; + creato_il: string; + data: string; + evento_id: string | null; + id: string; + parziali: Json; + set_loro: number; + set_nostri: number; + }; + Insert: { + avversario: string; + azioni?: Json; + casa?: boolean; + creato_il?: string; + data: string; + evento_id?: string | null; + id: string; + parziali?: Json; + set_loro: number; + set_nostri: number; + }; + Update: { + avversario?: string; + azioni?: Json; + casa?: boolean; + creato_il?: string; + data?: string; + evento_id?: string | null; + id?: string; + parziali?: Json; + set_loro?: number; + set_nostri?: number; + }; + Relationships: []; + }; scout_sessioni: { Row: { aggiornato_il: string; @@ -580,6 +754,9 @@ export type CompositeTypes< : never; export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { app_role: ["admin", "user"], diff --git a/src/lib/scout-live.ts b/src/lib/scout-live.ts index c1712c3..b3b7fd4 100644 --- a/src/lib/scout-live.ts +++ b/src/lib/scout-live.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; import { useEventi, type Evento } from "./eventi"; /** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */ @@ -30,79 +31,28 @@ export function sessioneScaduta(s: SessioneScout | null): boolean { return Date.now() - aggiornato > SCADENZA_MINUTI * 60_000; } -const storageKey = (eventoId: string) => `crap-scout-session-${eventoId}`; - -function readSession(eventoId: string): SessioneScout | null { - if (typeof window === "undefined") return null; - try { - const raw = window.localStorage.getItem(storageKey(eventoId)); - if (!raw) return null; - return JSON.parse(raw) as SessioneScout; - } catch { - return null; - } -} - -function writeSession(eventoId: string, sessione: SessioneScout | null) { - if (typeof window === "undefined") return; - if (sessione) { - window.localStorage.setItem(storageKey(eventoId), JSON.stringify(sessione)); - } else { - window.localStorage.removeItem(storageKey(eventoId)); - } - try { - const bc = new BroadcastChannel(`crap-scout-${eventoId}`); - bc.postMessage(sessione); - bc.close(); - } catch { - // fallback: storage event is already fired by localStorage - } -} - export const SESSIONE_KEY = (eventoId: string) => ["scout-sessione", eventoId] as const; +/** Sessione condivisa: chi la tiene aperta lo vede chiunque, su qualsiasi dispositivo. */ +async function leggiSessione(eventoId: string): Promise { + const { data, error } = await supabase + .from("scout_sessioni") + .select("evento_id, giocatore_id, giocatore_nome, aggiornato_il") + .eq("evento_id", eventoId) + .maybeSingle(); + if (error) throw error; + return data; +} + export function useSessioneScout(eventoId: string | null) { - const queryClient = useQueryClient(); - const query = useQuery({ + return useQuery({ queryKey: SESSIONE_KEY(eventoId ?? "-"), enabled: !!eventoId, - // Sincronizzazione via BroadcastChannel/storage: nessun polling. - staleTime: Infinity, - queryFn: async (): Promise => { - if (!eventoId || typeof window === "undefined") return null; - return readSession(eventoId); - }, + // Nessun push in tempo reale: si ricontrolla all'apertura/focus della pagina + // o con il pulsante "Aggiorna" quando risulta occupato. + staleTime: 30_000, + queryFn: () => leggiSessione(eventoId!), }); - - useEffect(() => { - if (!eventoId) return; - let bc: BroadcastChannel | null = null; - try { - bc = new BroadcastChannel(`crap-scout-${eventoId}`); - bc.onmessage = (e) => { - queryClient.setQueryData(SESSIONE_KEY(eventoId), e.data as SessioneScout | null); - }; - } catch { - const onStorage = (e: StorageEvent) => { - if (e.key === storageKey(eventoId)) { - queryClient.setQueryData( - SESSIONE_KEY(eventoId), - e.newValue ? (JSON.parse(e.newValue) as SessioneScout) : null, - ); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - } - return () => { - if (bc) { - bc.onmessage = null; - bc.close(); - } - }; - }, [eventoId, queryClient]); - - return query; } /** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */ @@ -110,17 +60,20 @@ export function useApriSessioneScout() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => { - if (typeof window === "undefined") return false; - const current = readSession(input.eventoId); - if (current && !sessioneScaduta(current) && current.giocatore_id !== input.giocatoreId) { + const attuale = await leggiSessione(input.eventoId); + if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) { return false; } - writeSession(input.eventoId, { - evento_id: input.eventoId, - giocatore_id: input.giocatoreId, - giocatore_nome: input.nome, - aggiornato_il: new Date().toISOString(), - }); + const { error } = await supabase.from("scout_sessioni").upsert( + { + evento_id: input.eventoId, + giocatore_id: input.giocatoreId, + giocatore_nome: input.nome, + aggiornato_il: new Date().toISOString(), + }, + { onConflict: "evento_id" }, + ); + if (error) throw error; return true; }, onSuccess: (_ok, input) => { @@ -133,10 +86,13 @@ export function useChiudiSessioneScout() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { eventoId: string; giocatoreId: string }) => { - if (typeof window === "undefined") return; - const current = readSession(input.eventoId); - if (current && current.giocatore_id === input.giocatoreId) { - writeSession(input.eventoId, null); + const attuale = await leggiSessione(input.eventoId); + if (attuale && attuale.giocatore_id === input.giocatoreId) { + const { error } = await supabase + .from("scout_sessioni") + .delete() + .eq("evento_id", input.eventoId); + if (error) throw error; } }, onSuccess: (_d, input) => { @@ -152,13 +108,13 @@ export function useHeartbeatScout( attivo: boolean, ) { useEffect(() => { - if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return; + if (!attivo || !eventoId || !giocatoreId) return; const id = window.setInterval(() => { - const current = readSession(eventoId); - if (current && current.giocatore_id === giocatoreId) { - current.aggiornato_il = new Date().toISOString(); - writeSession(eventoId, current); - } + void supabase + .from("scout_sessioni") + .update({ aggiornato_il: new Date().toISOString() }) + .eq("evento_id", eventoId) + .eq("giocatore_id", giocatoreId); }, 60_000); return () => window.clearInterval(id); }, [attivo, eventoId, giocatoreId]); diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index 6d3602b..d6b499b 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -1,4 +1,5 @@ -import { useSyncExternalStore } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; import { giocatori, type Giocatore } from "./crapp-data"; export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv"; @@ -67,54 +68,87 @@ export type ScoutMatch = { setNostri: number; setLoro: number; parziali: Array<[number, number]>; - mvp: string; azioni: Azione[]; }; -const KEY = "crapp-scout-v1"; +type RigaScoutPartita = { + id: string; + data: string; + avversario: string; + casa: boolean; + set_nostri: number; + set_loro: number; + parziali: unknown; + azioni: unknown; +}; -let cache: ScoutMatch[] | null = null; -const listeners = new Set<() => void>(); - -function read(): ScoutMatch[] { - if (cache) return cache; - if (typeof window === "undefined") return (cache = []); - try { - const raw = window.localStorage.getItem(KEY); - cache = raw ? (JSON.parse(raw) as ScoutMatch[]) : []; - } catch { - cache = []; - } - return cache; +function daRiga(r: RigaScoutPartita): ScoutMatch { + return { + id: r.id, + data: r.data, + avversario: r.avversario, + casa: r.casa, + setNostri: r.set_nostri, + setLoro: r.set_loro, + parziali: (r.parziali as Array<[number, number]> | null) ?? [], + azioni: (r.azioni as Azione[] | null) ?? [], + }; } -function write(next: ScoutMatch[]) { - cache = next; - try { - window.localStorage.setItem(KEY, JSON.stringify(next)); - } catch { - /* storage non disponibile */ - } - listeners.forEach((l) => l()); -} +export const SCOUT_MATCHES_KEY = ["scout-partite"] as const; -export function salvaScoutMatch(m: ScoutMatch) { - write([m, ...read()]); -} - -export function eliminaScoutMatch(id: string) { - write(read().filter((m) => m.id !== id)); +async function fetchScoutMatches(): Promise { + const { data, error } = await supabase + .from("scout_partite") + .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") + .order("creato_il", { ascending: false }); + if (error) throw error; + return (data ?? []).map(daRiga); } +/** Partite scoutate condivise con tutta la squadra: chi scoutizza le vede da qualsiasi + * dispositivo, non solo da quello di chi ha chiuso la partita. */ export function useScoutMatches(): ScoutMatch[] { - return useSyncExternalStore( - (cb) => { - listeners.add(cb); - return () => listeners.delete(cb); + const { data } = useQuery({ + queryKey: SCOUT_MATCHES_KEY, + staleTime: 60_000, + queryFn: fetchScoutMatches, + }); + return data ?? []; +} + +export function useSalvaScoutMatch() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { eventoId: string | null; match: ScoutMatch }) => { + const { error } = await supabase.from("scout_partite").insert({ + id: input.match.id, + evento_id: input.eventoId, + data: input.match.data, + avversario: input.match.avversario, + casa: input.match.casa, + set_nostri: input.match.setNostri, + set_loro: input.match.setLoro, + parziali: JSON.parse(JSON.stringify(input.match.parziali)), + azioni: JSON.parse(JSON.stringify(input.match.azioni)), + }); + if (error) throw error; + return input.match; }, - () => read(), - () => [], - ); + onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }), + }); +} + +export function useEliminaScoutMatch() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from("scout_partite").delete().eq("id", id); + if (error) throw error; + return id; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: SCOUT_MATCHES_KEY }), + }); } /** Somma delle azioni di un match per giocatore. */ diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 4cd5dd3..ce71298 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -52,7 +52,7 @@ function Index() { const ultima = csiGiocate[0] ? { ...matchDaPartitaCsi(csiGiocate[0]), mvp: mvpPerMatch[csiGiocate[0].id] ?? "" } : scoutMatches[0] - ? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? scoutMatches[0].mvp } + ? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? "" } : null; const obiettivi = useObiettivi(); const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null; diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index caa1ad2..7a45436 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -26,7 +26,7 @@ import { } from "@/lib/scout-live"; import { azioniMeta, - salvaScoutMatch, + useSalvaScoutMatch, totaliPerGiocatore, type Azione, type AzioneTipo, @@ -212,6 +212,7 @@ function ScoutBoard({ const [selezionato, setSelezionato] = useState(null); const salva = useSalvaStatoScout(); const cancella = useCancellaStatoScout(); + const salvaMatch = useSalvaScoutMatch(); const { risposte } = usePresenzeEvento(partita.id); const finito = useRef(false); @@ -280,7 +281,7 @@ function ScoutBoard({ toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`); } - function finePartita() { + async function finePartita() { const parziali: Array<[number, number]> = puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi; if (parziali.length === 0) { @@ -288,17 +289,24 @@ function ScoutBoard({ return; } const vinti = parziali.filter(([n, l]) => n > l).length; - salvaScoutMatch({ - id: `s${Date.now()}`, - data: new Date().toISOString().slice(0, 10), - avversario: avversario.trim() || "Avversario", - casa, - setNostri: vinti, - setLoro: parziali.length - vinti, - parziali, - mvp: "", - azioni, - }); + try { + await salvaMatch.mutateAsync({ + eventoId: partita.id, + match: { + id: `s${Date.now()}`, + data: new Date().toISOString().slice(0, 10), + avversario: avversario.trim() || "Avversario", + casa, + setNostri: vinti, + setLoro: parziali.length - vinti, + parziali, + azioni, + }, + }); + } catch { + toast.error("Salvataggio non riuscito, riprova"); + return; + } toast.success("Partita salvata: ora la squadra può votare l'MVP"); finito.current = true; void cancella.mutateAsync(partita.id); @@ -511,7 +519,8 @@ function ScoutBoard({ diff --git a/supabase/migrations/20260831120000_m4_solo_autenticati.sql b/supabase/migrations/20260831120000_m4_solo_autenticati.sql index 6ca74c7..9d38b70 100644 --- a/supabase/migrations/20260831120000_m4_solo_autenticati.sql +++ b/supabase/migrations/20260831120000_m4_solo_autenticati.sql @@ -12,6 +12,7 @@ REVOKE ALL ON public.push_subscriptions FROM anon; REVOKE ALL ON public.badge_social_voti FROM anon; REVOKE ALL ON public.scout_sessioni FROM anon; REVOKE ALL ON public.scout_live FROM anon; +REVOKE ALL ON public.scout_partite FROM anon; REVOKE ALL ON public.mvp_voti FROM anon; REVOKE ALL ON public.risposte_presenze FROM anon; REVOKE ALL ON public.eventi_app FROM anon; diff --git a/supabase/migrations/20260901150000_m7_scout_partite.sql b/supabase/migrations/20260901150000_m7_scout_partite.sql new file mode 100644 index 0000000..a433c97 --- /dev/null +++ b/supabase/migrations/20260901150000_m7_scout_partite.sql @@ -0,0 +1,42 @@ +-- M7 — Sincronizzazione dello Scout Live: blocco condiviso + archivio partite. +-- +-- Bug 1 (nessuna modifica di schema qui): "chi sta scoutando" viveva solo in +-- localStorage (src/lib/scout-live.ts), quindi "Scout occupato da X" non +-- funzionava mai tra telefoni diversi. La tabella scout_sessioni esiste già +-- dalla migration 20260731122724 con i campi giusti (evento_id, giocatore_id, +-- giocatore_nome, aggiornato_il) ma non era mai stata collegata al codice +-- (vedi docs/DATABASE.md). Qui si aggiorna solo il codice TypeScript. +-- +-- Bug 2: la partita scoutata finita veniva salvata solo in localStorage +-- (scout-store.ts, chiave crapp-scout-v1): "Punti/Ace/Muri squadra" in Squadra +-- e le presenze/MVP derivate dallo scout (useRosa) esistevano solo sul +-- telefono di chi aveva chiuso la partita. scout_partite archivia la partita +-- completa (azioni incluse) per tutta la squadra. + +CREATE TABLE public.scout_partite ( + id text PRIMARY KEY, + evento_id text, + data date NOT NULL, + avversario text NOT NULL, + casa boolean NOT NULL DEFAULT true, + set_nostri smallint NOT NULL, + set_loro smallint NOT NULL, + parziali jsonb NOT NULL DEFAULT '[]'::jsonb, + azioni jsonb NOT NULL DEFAULT '[]'::jsonb, + creato_il timestamptz NOT NULL DEFAULT now() +); + +GRANT SELECT, INSERT, UPDATE, DELETE ON public.scout_partite TO anon; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.scout_partite TO authenticated; +GRANT ALL ON public.scout_partite TO service_role; + +ALTER TABLE public.scout_partite ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Chiunque puo leggere le partite scoutate" ON public.scout_partite + FOR SELECT TO anon, authenticated USING (true); +CREATE POLICY "Chiunque puo salvare una partita scoutata" ON public.scout_partite + FOR INSERT TO anon, authenticated WITH CHECK (true); +CREATE POLICY "Chiunque puo modificare una partita scoutata" ON public.scout_partite + FOR UPDATE TO anon, authenticated USING (true) WITH CHECK (true); +CREATE POLICY "Chiunque puo eliminare una partita scoutata" ON public.scout_partite + FOR DELETE TO anon, authenticated USING (true); diff --git a/test/unit/scout-export.test.ts b/test/unit/scout-export.test.ts index bc0ba52..bd98a69 100644 --- a/test/unit/scout-export.test.ts +++ b/test/unit/scout-export.test.ts @@ -28,7 +28,6 @@ const match: ScoutMatch = { [25, 21], [25, 18], ], - mvp: g1.nome, azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")], }; diff --git a/test/unit/scout-store.test.ts b/test/unit/scout-store.test.ts index 1830d33..c8bcb61 100644 --- a/test/unit/scout-store.test.ts +++ b/test/unit/scout-store.test.ts @@ -31,7 +31,6 @@ const match = ( setNostri, setLoro, parziali: [], - mvp: "", azioni, });