Files
CRAPP/src/routes/scout.tsx
T
davideandClaude Opus 5 9b28a86f7c Aggiunge lo swipe al calendario e ripulisce le schermate
Calendario: il mese si cambiava solo con due frecce da 36 px. Ora si scorre,
e il punto d'arrivo si sceglie proiettando la velocità di rilascio invece che
dalla posizione del dito, così un colpo secco «lancia» il mese. La griglia
entra ed esce dallo stesso lato del gesto, `dragElastic` dà resistenza
progressiva al bordo al posto di uno stop netto.

Le celle con più tipi di evento generavano un gradiente a fette e ci mettevano
sopra un'ombra bianca sul numero per tenerlo leggibile: un cerotto su un
problema di contrasto. Ora fondo neutro e un puntino per tipo, con il numero
sempre su superficie piena. L'etichetta accessibile dice quanti eventi ci
sono, non solo che ce ne sono.

Profilo: le quattro switch «Notifiche convocazioni», «Promemoria allenamenti»,
«Cambi orario» e «Bacheca squadra» erano `defaultChecked` e non facevano
niente — promettevano una funzione che non esiste. Via anche la conferma
nativa prima di *cambiare* la foto: non è un'azione distruttiva, e chiedere
conferma per tutto insegna a rispondere sì senza leggere. Resta su quella che
la rimuove, che è irreversibile.

Home: la sezione «Da confermare» mostrava titolo e contenitore vuoto quando
non c'era altro da confermare, mentre tutte le altre sezioni hanno un empty
state.

Squadra: i badge in riga avevano solo `title=`, che su touch non appare mai —
aggiunto il testo per gli screen reader. I filtri per criterio erano alti
~26 px.

Più, in tutte le schermate: la primitiva `Card` al posto delle classi
ripetute, i bersagli sotto i 44 px portati in misura e il floor tipografico a
12 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 12:42:45 +02:00

534 lines
18 KiB
TypeScript

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 { Card } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { useGiocatoreCorrente } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
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,
useSalvaScoutMatch,
totaliPerGiocatore,
type Azione,
type AzioneTipo,
} 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,
});
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({
icona,
titolo,
testo,
children,
}: {
icona: React.ReactNode;
titolo: string;
testo: string;
children?: React.ReactNode;
}) {
return (
<div className="px-5 py-10">
<div className="rounded-3xl bg-card p-6 text-center shadow-card">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-secondary text-accent">
{icona}
</div>
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
<p className="mt-2 text-sm text-muted-foreground">{testo}</p>
{children}
</div>
</div>
);
}
function Scout() {
const { pronto, partita } = usePartitaDiOggi();
const io = useGiocatoreCorrente();
const admin = useIsAdmin();
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 (io && !admin) {
return (
<Blocco
icona={<Lock className="h-6 w-6" />}
titolo="Scout riservato"
testo="Lo scout live è uno strumento tecnico per allenatori e referenti della squadra."
/>
);
}
if (!pronto || sessione.isLoading || statoSalvato.isLoading) {
return (
<Blocco icona={<Radio className="h-6 w-6" />} titolo="Scout live" testo="Caricamento…" />
);
}
if (!partita) {
return (
<Blocco
icona={<CalendarX2 className="h-6 w-6" />}
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;
if (!controllo) {
return (
<Blocco
icona={occupataDaAltri ? <Lock className="h-6 w-6" /> : <Radio className="h-6 w-6" />}
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 ? (
<button
type="button"
onClick={() => sessione.refetch()}
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
Aggiorna
</button>
) : (
<button
type="button"
disabled={apri.isPending || !io}
onClick={async () => {
if (!io) return;
const ok = await apri.mutateAsync({
eventoId: partita.id,
giocatoreId: io.id,
nome: io.nome,
});
if (ok) setControllo(true);
else toast.error("Un altro compagno ha appena preso lo scout");
}}
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
</button>
)}
</Blocco>
);
}
return (
<ScoutBoard
partita={partita}
iniziale={statoSalvato.data ?? null}
onFine={() => {
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, setAvversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
const [selezionato, setSelezionato] = useState<string | null>(null);
const salva = useSalvaStatoScout();
const cancella = useCancellaStatoScout();
const salvaMatch = useSalvaScoutMatch();
const { risposte } = usePresenzeEvento(partita.id);
const rosa = useRosa();
const finito = useRef(false);
/** 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",
);
return presenti.length > 0 ? presenti : rosa;
}, [risposte, rosa]);
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
useEffect(() => {
if (finito.current) return;
const id = window.setTimeout(() => {
void salva.mutateAsync({
eventoId: partita.id,
stato: { azioni, setChiusi, avversario, casa },
});
}, 800);
return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [azioni, 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 setNostri = setChiusi.filter(([n, l]) => n > l).length;
const setLoro = setChiusi.length - setNostri;
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
function registra(tipo: AzioneTipo) {
const meta = azioniMeta[tipo];
if (meta.richiedeGiocatore && !selezionato) {
toast.error("Seleziona prima il giocatore");
return;
}
setAzioni((prev) => {
const nuova: Azione = {
id: `${Date.now()}-${prev.length}`,
tipo,
set: setCorrente,
ts: Date.now(),
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}),
};
return [...prev, nuova];
});
}
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 (puntiNoi === 0 && puntiLoro === 0) {
toast.error("Set senza azioni");
return;
}
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
}
async function finePartita() {
const parziali: Array<[number, number]> =
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
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,
},
});
} 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);
onFine();
navigate({ to: "/squadra" });
}
const ultime = [...azioni].slice(-4).reverse();
return (
<>
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-xs font-bold uppercase tracking-wide">
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={annulla}
disabled={azioniSet.length === 0}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
>
<Undo2 className="h-3.5 w-3.5" /> Annulla
</button>
<button
type="button"
onClick={onFine}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
>
<LogOut className="h-3.5 w-3.5" /> Rilascia
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<div className="min-w-0">
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-xs uppercase text-primary-foreground/60">
{avversario || "Avversario"}
</p>
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
</div>
</div>
{setChiusi.length > 0 ? (
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
{setChiusi.map((p, i) => (
<span
key={i}
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
) : null}
</header>
<section className="px-5 pt-4">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
1. Tocca il giocatore
</p>
<div className="grid grid-cols-4 gap-2">
{convocati.map((g) => {
const attivo = selezionato === g.id;
const t = totali.get(g.id);
return (
<button
key={g.id}
type="button"
onClick={() => setSelezionato(attivo ? null : g.id)}
className={cn(
"rounded-2xl p-2 text-center transition-all active:scale-95",
attivo ? "bg-accent text-accent-foreground shadow-pop" : "bg-card shadow-card",
)}
>
<p className="font-display text-2xl leading-none">{g.numero}</p>
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
<p
className={cn(
"text-xs tabular-nums",
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)}
>
{t ? `${t.punti}p` : "0p"}
</p>
</button>
);
})}
</div>
</section>
<section className="px-5 pt-4">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
2. Tocca l'azione
</p>
<div className="grid grid-cols-2 gap-2">
{ordineAzioni.map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
!selezionato && "opacity-50",
)}
>
{azioniMeta[tipo].label}
</button>
))}
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => (
<button
key={tipo}
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
)}
>
{azioniMeta[tipo].short}
</button>
))}
</div>
</section>
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-3 shadow-card">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni
</p>
{ultime.length === 0 ? (
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
) : (
<ul className="mt-2 space-y-1.5">
{ultime.map((a, i) => {
const g = rosa.find((x) => x.id === a.giocatoreId);
return (
<li
key={a.id}
className={cn(
"flex items-center justify-between gap-2 px-1 text-xs",
i === 0 && "anim-riga",
)}
>
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-bold uppercase",
azioniMeta[a.tipo].className,
)}
>
{azioniMeta[a.tipo].short}
</span>
</li>
);
})}
</ul>
)}
</div>
</section>
<section className="px-5 pt-4">
<Card>
<label className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Avversario
</label>
<input
value={avversario}
maxLength={40}
onChange={(e) => setAvversario(e.target.value)}
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
<div className="mt-3 flex rounded-full bg-secondary p-1">
{[true, false].map((v) => (
<button
key={String(v)}
type="button"
onClick={() => setCasa(v)}
className={cn(
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
)}
>
{v ? "In casa" : "Trasferta"}
</button>
))}
</div>
</Card>
</section>
<section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
<button
type="button"
onClick={chiudiSet}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
<CheckCircle2 className="h-4 w-4" /> Chiudi set
</button>
<button
type="button"
onClick={finePartita}
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
</button>
</section>
</>
);
}