Files
CRAPP/src/lib/scout-live.ts
T
davideandClaude Sonnet 5 7ef7eef963 Calcola "oggi" nel fuso di Roma ovunque, invece di UTC o del fuso del processo.
serieConsecutiva()/serieConferme() calcolavano oggi con new Date().toISOString()
(sempre UTC), mentre contaPresenzeGiocatore() usava dataOggi() con i getter
locali di Date (corretti solo se il processo gira già in fuso italiano — falso
su un server SSR in UTC). Le due statistiche potevano non essere d'accordo su
cosa fosse "oggi" nelle prime ore della giornata italiana.

dataOggi() ora usa Intl.DateTimeFormat con timeZone: "Europe/Rome": il cambio
ora legale/solare lo gestisce il database IANA dei fusi, non un offset scritto
a mano. Le funzioni di serie in presenze.ts usano lo stesso dataOggi() invece
di un oggiIso() locale, così tutte le statistiche restano coerenti fra loro.

Aggiunti test che dimostrano il fix con istanti reali a cavallo di mezzanotte
sia in CET che in CEST, per provare che lo scarto segue davvero il fuso e non
un offset fisso.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:47:02 +02:00

139 lines
5.0 KiB
TypeScript

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. */
export const SCADENZA_MINUTI = 5;
/**
* Data di oggi in Italia (`Europe/Rome`, con cambio ora legale/solare gestito dal database
* IANA dei fusi orari). Non i getter locali di `Date` — quelli seguono il fuso del processo
* che esegue il codice: sul client di chi è in Italia coincide, ma lato server (SSR, dev
* container) è spesso UTC, e `toISOString()` lo è sempre. Sbagliare qui sposta la mezzanotte:
* nella prima ora o due della giornata italiana, eventi già passati risulterebbero "di domani".
*/
export function dataOggi(adesso: Date = new Date()): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(adesso);
}
/** La partita in programma oggi, se c'è. */
export function partitaDiOggi(eventi: Evento[], oggi = dataOggi()): Evento | null {
return eventi.find((e) => e.tipo === "partita" && e.data === oggi) ?? null;
}
export type SessioneScout = {
evento_id: string;
giocatore_id: string;
giocatore_nome: string;
aggiornato_il: string;
};
export function sessioneScaduta(s: SessioneScout | null): boolean {
if (!s) return true;
const aggiornato = new Date(s.aggiornato_il).getTime();
// Timestamp illeggibile: meglio liberare la sessione che lasciarla bloccata per sempre.
if (Number.isNaN(aggiornato)) return true;
return Date.now() - aggiornato > SCADENZA_MINUTI * 60_000;
}
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) {
return useQuery({
queryKey: SESSIONE_KEY(eventoId ?? "-"),
enabled: !!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!),
});
}
/** Prende il controllo dello scout se libero o scaduto. Ritorna true se ottenuto. */
export function useApriSessioneScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string; nome: string }) => {
const attuale = await leggiSessione(input.eventoId);
if (attuale && !sessioneScaduta(attuale) && attuale.giocatore_id !== input.giocatoreId) {
return false;
}
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) => {
queryClient.invalidateQueries({ queryKey: SESSIONE_KEY(input.eventoId) });
},
});
}
export function useChiudiSessioneScout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { eventoId: string; giocatoreId: string }) => {
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) => {
queryClient.invalidateQueries({ queryKey: SESSIONE_KEY(input.eventoId) });
},
});
}
/** Mantiene viva la sessione mentre lo scout è aperto. */
export function useHeartbeatScout(
eventoId: string | null,
giocatoreId: string | null,
attivo: boolean,
) {
useEffect(() => {
if (!attivo || !eventoId || !giocatoreId) return;
const id = window.setInterval(() => {
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]);
}
/** Versione SSR-safe: null finché il client non è montato. */
export function usePartitaDiOggi(): { pronto: boolean; partita: Evento | null } {
const { eventi, isPending } = useEventi();
const [montato, setMontato] = useState(false);
useEffect(() => {
setMontato(true);
}, []);
if (!montato || isPending) return { pronto: false, partita: null };
return { pronto: true, partita: partitaDiOggi(eventi) };
}