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:
+3
-2
@@ -36,8 +36,9 @@ non in questo file.
|
|||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| 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_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` | Eventi registrati durante lo Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). |
|
| `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
|
## Votazioni
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,32 @@ export type Database = {
|
|||||||
// Allows to automatically instantiate createClient with right options
|
// Allows to automatically instantiate createClient with right options
|
||||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||||
__InternalSupabase: {
|
__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: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
@@ -194,6 +219,45 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Relationships: [];
|
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: {
|
mvp_voti: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string;
|
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: {
|
promemoria_push: {
|
||||||
Row: {
|
Row: {
|
||||||
creato_il: string;
|
creato_il: string;
|
||||||
@@ -386,6 +521,45 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Relationships: [];
|
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: {
|
scout_sessioni: {
|
||||||
Row: {
|
Row: {
|
||||||
aggiornato_il: string;
|
aggiornato_il: string;
|
||||||
@@ -580,6 +754,9 @@ export type CompositeTypes<
|
|||||||
: never;
|
: never;
|
||||||
|
|
||||||
export const Constants = {
|
export const Constants = {
|
||||||
|
graphql_public: {
|
||||||
|
Enums: {},
|
||||||
|
},
|
||||||
public: {
|
public: {
|
||||||
Enums: {
|
Enums: {
|
||||||
app_role: ["admin", "user"],
|
app_role: ["admin", "user"],
|
||||||
|
|||||||
+42
-86
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import { useEventi, type Evento } from "./eventi";
|
import { useEventi, type Evento } from "./eventi";
|
||||||
|
|
||||||
/** Minuti dopo i quali una sessione scout inattiva viene considerata libera. */
|
/** 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;
|
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;
|
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) {
|
export function useSessioneScout(eventoId: string | null) {
|
||||||
const queryClient = useQueryClient();
|
return useQuery({
|
||||||
const query = useQuery({
|
|
||||||
queryKey: SESSIONE_KEY(eventoId ?? "-"),
|
queryKey: SESSIONE_KEY(eventoId ?? "-"),
|
||||||
enabled: !!eventoId,
|
enabled: !!eventoId,
|
||||||
// Sincronizzazione via BroadcastChannel/storage: nessun polling.
|
// Nessun push in tempo reale: si ricontrolla all'apertura/focus della pagina
|
||||||
staleTime: Infinity,
|
// o con il pulsante "Aggiorna" quando risulta occupato.
|
||||||
queryFn: async (): Promise<SessioneScout | null> => {
|
staleTime: 30_000,
|
||||||
if (!eventoId || typeof window === "undefined") return null;
|
queryFn: () => leggiSessione(eventoId!),
|
||||||
return readSession(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. */
|
/** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */
|
||||||
@@ -110,17 +60,20 @@ export function useApriSessioneScout() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
|
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
|
||||||
if (typeof window === "undefined") return false;
|
const attuale = await leggiSessione(input.eventoId);
|
||||||
const current = readSession(input.eventoId);
|
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
|
||||||
if (current && !sessioneScaduta(current) && current.giocatore_id !== input.giocatoreId) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
writeSession(input.eventoId, {
|
const { error } = await supabase.from("scout_sessioni").upsert(
|
||||||
evento_id: input.eventoId,
|
{
|
||||||
giocatore_id: input.giocatoreId,
|
evento_id: input.eventoId,
|
||||||
giocatore_nome: input.nome,
|
giocatore_id: input.giocatoreId,
|
||||||
aggiornato_il: new Date().toISOString(),
|
giocatore_nome: input.nome,
|
||||||
});
|
aggiornato_il: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ onConflict: "evento_id" },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onSuccess: (_ok, input) => {
|
onSuccess: (_ok, input) => {
|
||||||
@@ -133,10 +86,13 @@ export function useChiudiSessioneScout() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
|
||||||
if (typeof window === "undefined") return;
|
const attuale = await leggiSessione(input.eventoId);
|
||||||
const current = readSession(input.eventoId);
|
if (attuale && attuale.giocatore_id === input.giocatoreId) {
|
||||||
if (current && current.giocatore_id === input.giocatoreId) {
|
const { error } = await supabase
|
||||||
writeSession(input.eventoId, null);
|
.from("scout_sessioni")
|
||||||
|
.delete()
|
||||||
|
.eq("evento_id", input.eventoId);
|
||||||
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (_d, input) => {
|
onSuccess: (_d, input) => {
|
||||||
@@ -152,13 +108,13 @@ export function useHeartbeatScout(
|
|||||||
attivo: boolean,
|
attivo: boolean,
|
||||||
) {
|
) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!attivo || !eventoId || !giocatoreId || typeof window === "undefined") return;
|
if (!attivo || !eventoId || !giocatoreId) return;
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
const current = readSession(eventoId);
|
void supabase
|
||||||
if (current && current.giocatore_id === giocatoreId) {
|
.from("scout_sessioni")
|
||||||
current.aggiornato_il = new Date().toISOString();
|
.update({ aggiornato_il: new Date().toISOString() })
|
||||||
writeSession(eventoId, current);
|
.eq("evento_id", eventoId)
|
||||||
}
|
.eq("giocatore_id", giocatoreId);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [attivo, eventoId, giocatoreId]);
|
}, [attivo, eventoId, giocatoreId]);
|
||||||
|
|||||||
+72
-38
@@ -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";
|
import { giocatori, type Giocatore } from "./crapp-data";
|
||||||
|
|
||||||
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
export type AzioneTipo = "attacco" | "ace" | "muro" | "errore" | "punto_avv" | "errore_avv";
|
||||||
@@ -67,54 +68,87 @@ export type ScoutMatch = {
|
|||||||
setNostri: number;
|
setNostri: number;
|
||||||
setLoro: number;
|
setLoro: number;
|
||||||
parziali: Array<[number, number]>;
|
parziali: Array<[number, number]>;
|
||||||
mvp: string;
|
|
||||||
azioni: Azione[];
|
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;
|
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||||
const listeners = new Set<() => void>();
|
return {
|
||||||
|
id: r.id,
|
||||||
function read(): ScoutMatch[] {
|
data: r.data,
|
||||||
if (cache) return cache;
|
avversario: r.avversario,
|
||||||
if (typeof window === "undefined") return (cache = []);
|
casa: r.casa,
|
||||||
try {
|
setNostri: r.set_nostri,
|
||||||
const raw = window.localStorage.getItem(KEY);
|
setLoro: r.set_loro,
|
||||||
cache = raw ? (JSON.parse(raw) as ScoutMatch[]) : [];
|
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
|
||||||
} catch {
|
azioni: (r.azioni as Azione[] | null) ?? [],
|
||||||
cache = [];
|
};
|
||||||
}
|
|
||||||
return cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(next: ScoutMatch[]) {
|
export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||||
cache = next;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
/* storage non disponibile */
|
|
||||||
}
|
|
||||||
listeners.forEach((l) => l());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function salvaScoutMatch(m: ScoutMatch) {
|
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||||
write([m, ...read()]);
|
const { data, error } = await supabase
|
||||||
}
|
.from("scout_partite")
|
||||||
|
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
||||||
export function eliminaScoutMatch(id: string) {
|
.order("creato_il", { ascending: false });
|
||||||
write(read().filter((m) => m.id !== id));
|
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[] {
|
export function useScoutMatches(): ScoutMatch[] {
|
||||||
return useSyncExternalStore(
|
const { data } = useQuery({
|
||||||
(cb) => {
|
queryKey: SCOUT_MATCHES_KEY,
|
||||||
listeners.add(cb);
|
staleTime: 60_000,
|
||||||
return () => listeners.delete(cb);
|
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. */
|
/** Somma delle azioni di un match per giocatore. */
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function Index() {
|
|||||||
const ultima = csiGiocate[0]
|
const ultima = csiGiocate[0]
|
||||||
? { ...matchDaPartitaCsi(csiGiocate[0]), mvp: mvpPerMatch[csiGiocate[0].id] ?? "" }
|
? { ...matchDaPartitaCsi(csiGiocate[0]), mvp: mvpPerMatch[csiGiocate[0].id] ?? "" }
|
||||||
: scoutMatches[0]
|
: scoutMatches[0]
|
||||||
? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? scoutMatches[0].mvp }
|
? { ...scoutMatches[0], mvp: mvpPerMatch[scoutMatches[0].id] ?? "" }
|
||||||
: null;
|
: null;
|
||||||
const obiettivi = useObiettivi();
|
const obiettivi = useObiettivi();
|
||||||
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
||||||
|
|||||||
+23
-14
@@ -26,7 +26,7 @@ import {
|
|||||||
} from "@/lib/scout-live";
|
} from "@/lib/scout-live";
|
||||||
import {
|
import {
|
||||||
azioniMeta,
|
azioniMeta,
|
||||||
salvaScoutMatch,
|
useSalvaScoutMatch,
|
||||||
totaliPerGiocatore,
|
totaliPerGiocatore,
|
||||||
type Azione,
|
type Azione,
|
||||||
type AzioneTipo,
|
type AzioneTipo,
|
||||||
@@ -212,6 +212,7 @@ function ScoutBoard({
|
|||||||
const [selezionato, setSelezionato] = useState<string | null>(null);
|
const [selezionato, setSelezionato] = useState<string | null>(null);
|
||||||
const salva = useSalvaStatoScout();
|
const salva = useSalvaStatoScout();
|
||||||
const cancella = useCancellaStatoScout();
|
const cancella = useCancellaStatoScout();
|
||||||
|
const salvaMatch = useSalvaScoutMatch();
|
||||||
const { risposte } = usePresenzeEvento(partita.id);
|
const { risposte } = usePresenzeEvento(partita.id);
|
||||||
const finito = useRef(false);
|
const finito = useRef(false);
|
||||||
|
|
||||||
@@ -280,7 +281,7 @@ function ScoutBoard({
|
|||||||
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
|
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function finePartita() {
|
async function finePartita() {
|
||||||
const parziali: Array<[number, number]> =
|
const parziali: Array<[number, number]> =
|
||||||
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
|
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
|
||||||
if (parziali.length === 0) {
|
if (parziali.length === 0) {
|
||||||
@@ -288,17 +289,24 @@ function ScoutBoard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vinti = parziali.filter(([n, l]) => n > l).length;
|
const vinti = parziali.filter(([n, l]) => n > l).length;
|
||||||
salvaScoutMatch({
|
try {
|
||||||
id: `s${Date.now()}`,
|
await salvaMatch.mutateAsync({
|
||||||
data: new Date().toISOString().slice(0, 10),
|
eventoId: partita.id,
|
||||||
avversario: avversario.trim() || "Avversario",
|
match: {
|
||||||
casa,
|
id: `s${Date.now()}`,
|
||||||
setNostri: vinti,
|
data: new Date().toISOString().slice(0, 10),
|
||||||
setLoro: parziali.length - vinti,
|
avversario: avversario.trim() || "Avversario",
|
||||||
parziali,
|
casa,
|
||||||
mvp: "",
|
setNostri: vinti,
|
||||||
azioni,
|
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");
|
toast.success("Partita salvata: ora la squadra può votare l'MVP");
|
||||||
finito.current = true;
|
finito.current = true;
|
||||||
void cancella.mutateAsync(partita.id);
|
void cancella.mutateAsync(partita.id);
|
||||||
@@ -511,7 +519,8 @@ function ScoutBoard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={finePartita}
|
onClick={finePartita}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
disabled={salvaMatch.isPending}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" /> Fine partita
|
<Save className="h-4 w-4" /> Fine partita
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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.badge_social_voti FROM anon;
|
||||||
REVOKE ALL ON public.scout_sessioni FROM anon;
|
REVOKE ALL ON public.scout_sessioni FROM anon;
|
||||||
REVOKE ALL ON public.scout_live 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.mvp_voti FROM anon;
|
||||||
REVOKE ALL ON public.risposte_presenze FROM anon;
|
REVOKE ALL ON public.risposte_presenze FROM anon;
|
||||||
REVOKE ALL ON public.eventi_app FROM anon;
|
REVOKE ALL ON public.eventi_app FROM anon;
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -28,7 +28,6 @@ const match: ScoutMatch = {
|
|||||||
[25, 21],
|
[25, 21],
|
||||||
[25, 18],
|
[25, 18],
|
||||||
],
|
],
|
||||||
mvp: g1.nome,
|
|
||||||
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
|
azioni: [a("attacco", g1.id), a("ace", g1.id, 2), a("errore", g2.id, 2), a("punto_avv")],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ const match = (
|
|||||||
setNostri,
|
setNostri,
|
||||||
setLoro,
|
setLoro,
|
||||||
parziali: [],
|
parziali: [],
|
||||||
mvp: "",
|
|
||||||
azioni,
|
azioni,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user