import { useEffect, useMemo, useRef, useState } from "react";
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 { Select } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import {
statoIniziale,
useCancellaStatoScout,
useSalvaStatoScout,
useStatoScout,
type StatoScout,
} from "@/lib/scout-stato";
import {
SCADENZA_MINUTI,
sessioneScaduta,
useApriSessioneScout,
useChiudiSessioneScout,
useHeartbeatScout,
usePartitaDiOggi,
useSessioneScout,
} from "@/lib/scout-live";
import {
azioniMeta,
contaPiuSenzaRicezione,
contaValutazioni,
parzialiFinali,
fondamentali,
useSalvaScoutMatch,
type Azione,
type AzioneTipo,
type Fondamentale,
type Valutazione,
} from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({
head: () => ({
meta: [
{ title: "Scout live partita — CrAPP" },
{
name: "description",
content:
"Registra punti, ace, muri ed errori del CRAP Volley in tempo reale durante la partita.",
},
{ property: "og:title", content: "Scout live partita — CrAPP" },
{
property: "og:description",
content: "Un tap per giocatore e azione: le stats finiscono subito nel database squadra.",
},
],
}),
component: Scout,
});
function Blocco({
icona,
titolo,
testo,
children,
}: {
icona: React.ReactNode;
titolo: string;
testo: string;
children?: React.ReactNode;
}) {
return (
{icona}
{titolo}
{testo}
{children}
);
}
function Scout() {
const { pronto, partita } = usePartitaDiOggi();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const sessione = useSessioneScout(partita?.id ?? null);
const statoSalvato = useStatoScout(partita?.id ?? null);
const apri = useApriSessioneScout();
const chiudi = useChiudiSessioneScout();
const [controllo, setControllo] = useState(false);
useHeartbeatScout(partita?.id ?? null, io?.id ?? null, controllo);
useEffect(() => {
if (!controllo || !partita || !io) return;
const rilascia = () => {
void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id });
};
window.addEventListener("pagehide", rilascia);
return () => window.removeEventListener("pagehide", rilascia);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controllo, partita?.id, io?.id]);
if (!pronto || sessione.isLoading || statoSalvato.isLoading) {
return (
} titolo="Scout live" testo="Caricamento…" />
);
}
if (!partita) {
return (
}
titolo="Nessuna partita oggi"
testo="Lo scout live si attiva automaticamente il giorno della partita."
/>
);
}
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 ||
(statoSalvato.data.valutazioni?.length ?? 0) > 0 ||
statoSalvato.data.setChiusi.length > 0);
if (!controllo) {
return (
: }
titolo={occupataDaAltri ? "Scout occupato" : "Scout live disponibile"}
testo={
occupataDaAltri
? `${attiva!.giocatore_nome} sta già scoutando questa partita. Si libera dopo ${SCADENZA_MINUTI} minuti di inattività.`
: ripresa
? `${partita.titolo} · scout già avviato: riprendi da dove è stato lasciato.`
: `${partita.titolo} · ${formatData(partita.data)} ore ${partita.ora}`
}
>
{occupataDaAltri ? (
) : (
)}
);
}
return (
{
setControllo(false);
if (io) void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id });
}}
/>
);
}
function ScoutBoard({
partita,
iniziale,
onFine,
}: {
partita: Evento;
iniziale: StatoScout | null;
onFine: () => void;
}) {
const navigate = useNavigate();
const base =
iniziale ??
statoIniziale(
partita.titolo
.replace(/CRAP Volley/gi, "")
.replace(/\s*vs\s*/i, "")
.trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"),
);
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 [ordine, setOrdine] = useState<"cognome" | "numero">("cognome");
const [selezionato, setSelezionato] = useState(null);
const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout();
const salvaMatch = useSalvaScoutMatch();
const { risposte } = usePresenzeEvento(partita.id);
// 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(() => {
const presenti = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
);
const elenco = presenti.length > 0 ? presenti : rosa;
return [...elenco].sort((a, b) =>
ordine === "numero"
? a.numero - b.numero
: a.cognome.localeCompare(b.cognome, "it") || a.numero - b.numero,
);
}, [risposte, rosa, ordine]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => {
if (finito.current) return;
// 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,
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
if (!selezionato) {
toast.error("Seleziona prima il giocatore");
return;
}
setValutazioni((prev) => [
...prev,
{
id: `${Date.now()}-${prev.length}`,
giocatoreId: selezionato,
fondamentale,
segno,
set: setCorrente,
ts: Date.now(),
},
]);
}
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() {
const ultima = azioni[azioni.length - 1];
if (!ultima || ultima.set !== setCorrente) {
toast.error("Puoi annullare solo le azioni del set in corso");
return;
}
setAzioni((prev) => prev.slice(0, -1));
}
function chiudiSet() {
if (!setHaDati) {
toast.error("Set senza valutazioni");
return;
}
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso`);
}
async function finePartita() {
const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
if (parziali.length === 0) {
toast.error("Nessun set da salvare");
return;
}
const vinti = parziali.filter(([n, l]) => n > l).length;
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,
valutazioni,
},
});
} catch {
toast.error("Salvataggio non riuscito, riprova");
return;
}
toast.success("Partita salvata");
finito.current = true;
void cancella.mutateAsync(partita.id);
onFine();
navigate({ to: "/squadra" });
}
const giocatoreSel = rosa.find((g) => g.id === selezionato);
const contiSelezionato = selezionato
? contaValutazioni(valutazioni, selezionato, setCorrente)
: null;
return (
<>
Scout live · Set {setCorrente}
Selezione giocatore
{convocati.map((g) => {
const attivo = selezionato === g.id;
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
return (
);
})}
Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
{setCorrente}
{!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}
);
})}
>
);
}