Sincronizza lo Scout Live tra dispositivi (blocco e archivio partite)

Due bug architetturali, entrambi con la stessa causa: dati che vivevano
solo in localStorage, quindi visibili a un solo dispositivo.

- Il blocco "chi sta scoutando" (scout-live.ts) non funzionava mai tra
  telefoni diversi: due persone potevano prendere il controllo insieme
  da dispositivi diversi, sovrascrivendosi a vicenda le azioni nel
  salvataggio condiviso. Ora usa scout_sessioni, tabella già presente
  nello schema ma mai collegata al codice.

- La partita scoutata finita (scout-store.ts) veniva salvata solo in
  localStorage: "Punti/Ace/Muri squadra" e le presenze derivate dallo
  scout esistevano solo sul telefono di chi aveva chiuso la partita.
  Nuova tabella scout_partite archivia la partita completa (azioni
  incluse) per tutta la squadra.

useScoutMatches() mantiene la stessa firma di prima (ScoutMatch[]), ora
alimentata da una query invece che da localStorage: nessuna modifica
necessaria nei punti che la leggono (squadra, classifica, home,
dettaglio partita, rosa). Rigenerati i tipi Supabase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:08:59 +02:00
co-authored by Claude Sonnet 5
parent d2d62b6799
commit c314a08f6d
10 changed files with 362 additions and 144 deletions
+42 -86
View File
@@ -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<SessioneScout | null> {
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<SessioneScout | null> => {
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]);
+72 -38
View File
@@ -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<ScoutMatch[]> {
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. */