diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c37cf94..5e4db1e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,7 +13,13 @@ versioni `0.y.z` erano pre-release. ## [Non rilasciato] -Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**. +Cinque migration, da `m35` a `m39`, **non ancora applicate in produzione**. + +### Aggiunto + +- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro, + separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il + CSV admin della partita include una tabella con i conteggi per set. ### Modificato diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 6d50370..1971f2b 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -88,7 +88,7 @@ Colonne di `giocatori_squadra` che meritano una nota: | ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `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`. | | `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. | -| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. | +| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. | ## Votazioni diff --git a/docs/decisions/DD-053.md b/docs/decisions/DD-053.md new file mode 100644 index 0000000..a93710d --- /dev/null +++ b/docs/decisions/DD-053.md @@ -0,0 +1,33 @@ +# DD-053 — Valutazioni − / + dei fondamentali nello Scout Live + +**Data:** 2 novembre 2026 +**Stato:** Accettata (migration `m39`) + +**Contesto** +Lo scout registrava solo ciò che fa punto o lo toglie (attacco, ace, muro, errore). Mancava un +giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non cambia il punteggio. + +**Decisione** + +- Per ogni convocato, un **−** o un **+** su battuta, ricezione, attacco e muro, **per set**. +- Sono valutazioni, non azioni: lista `valutazioni` separata da `azioni`, così non entrano nel + punteggio, nei parziali né nei totali P/A/M/E (`totaliPerGiocatore`, badge, classifiche: DD-008). +- Ogni tocco è una riga (giocatore, fondamentale, segno, set, istante): permette l'annulla e il + conteggio per set senza strutture da tenere allineate. +- Stato in corso dentro `scout_live.stato` (jsonb, nessuna modifica); partita archiviata nella + nuova colonna `scout_partite.valutazioni jsonb NOT NULL DEFAULT '[]'` (`m39`, solo additiva). + +**Alternative scartate** + +- Nuovi `AzioneTipo` dentro `azioni` → ogni consumatore (punteggio, totali, CSV) andrebbe filtrato + per non contarli come punti. +- Solo contatori aggregati per giocatore/set → niente annulla preciso, né ordine cronologico. +- Tabella dedicata → per un dato che nasce e muore con la partita archiviata è più schema del + necessario. + +**Conseguenze** +Le partite già archiviate hanno `valutazioni = []`. Le valutazioni sono visibili solo nel CSV +admin; sono dato di squadra a uso tecnico, non entrano in classifiche individuali. + +**Riesame** +Se servirà confrontare le valutazioni tra partite o mostrarle ai giocatori. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c7db000..be064a7 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -89,6 +89,7 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand | [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento | | [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito | | [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura | +| [DD-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live | | [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app | | [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-047](DD-047.md) | Notifica di compleanno | diff --git a/docs/modules/scout-live.md b/docs/modules/scout-live.md index 32c3950..0057799 100644 --- a/docs/modules/scout-live.md +++ b/docs/modules/scout-live.md @@ -10,7 +10,8 @@ ## Obiettivo Permettere a un solo referente per volta di registrare in tempo reale, durante la partita, -punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su +punti, ace, muri ed errori di ciascun giocatore in campo — più un giudizio − / + sui +fondamentali, per set — con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato aggiornato da qualunque dispositivo. @@ -21,7 +22,8 @@ aggiornato da qualunque dispositivo. - `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per `evento_id`, quindi un solo detentore). - `scout_live` — stato in corso (azioni non ancora concluse) di una sessione. -- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni), +- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni, + valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero). - `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento (`UNIQUE evento_id, giocatore_id`). @@ -74,6 +76,21 @@ risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni c --- +## Valutazioni dei fondamentali (− / +) + +Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro +([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai +totali P/A/M/E. Si seleziona il giocatore (passo 1) e si tocca − o + sul fondamentale (passo 3); +accanto a ogni tasto compare il conteggio di quel giocatore **nel set in corso**. «Annulla» nella +sezione toglie l'ultima valutazione del set in corso (l'«Annulla» in alto resta per le azioni). + +Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una +lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()` +(`scout-store.ts`). Lo stato in corso le salva in `scout_live.stato.valutazioni` (assente negli +stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_partite.valutazioni`. + +--- + ## Fine partita `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in @@ -85,8 +102,10 @@ e rilascia la sessione. ## Export CSV `scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per -giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita, -sezione "Report tecnico". +giocatore e log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con i + +- e i − di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla pagina partita, + sezione "Report tecnico". --- @@ -127,4 +146,5 @@ calcolati a runtime). ## Evoluzioni possibili +- Mostrare le valutazioni anche nella pagina della partita (oggi solo nel CSV admin). - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 3205698..f272d6f 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -536,6 +536,7 @@ export type Database = { parziali: Json; set_loro: number; set_nostri: number; + valutazioni: Json; }; Insert: { avversario: string; @@ -548,6 +549,7 @@ export type Database = { parziali?: Json; set_loro: number; set_nostri: number; + valutazioni?: Json; }; Update: { avversario?: string; @@ -560,6 +562,7 @@ export type Database = { parziali?: Json; set_loro?: number; set_nostri?: number; + valutazioni?: Json; }; Relationships: []; }; diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 62d4f5d..9fbb25b 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string { export function useAnagraficaRosa({ conAllenatori = false, }: { conAllenatori?: boolean } = {}): Array< - Pick + Pick & { cognome: string } > { const { righe: squadra } = useGiocatoriSquadra(); return useMemo( @@ -44,6 +44,7 @@ export function useAnagraficaRosa({ .map((g) => ({ id: g.id, nome: nomeCompleto(g), + cognome: g.cognome, ruolo: g.ruolo, numero: g.numero, nascita: g.nascita ?? "", diff --git a/src/lib/scout-export.ts b/src/lib/scout-export.ts index bafe5cc..d0d7dc5 100644 --- a/src/lib/scout-export.ts +++ b/src/lib/scout-export.ts @@ -1,5 +1,11 @@ import type { Giocatore } from "./crapp-data"; -import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; +import { + azioniMeta, + contaValutazioni, + fondamentali, + totaliPerGiocatore, + type ScoutMatch, +} from "./scout-store"; /** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ export function rigaCsv(campi: Array) { @@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string { if (!t) continue; righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); } + if (match.valutazioni.length > 0) { + righe.push(""); + righe.push( + rigaCsv([ + "Set", + "Numero", + "Giocatore", + ...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]), + ]), + ); + const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y); + for (const set of sets) { + for (const g of rosa) { + if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue; + const c = contaValutazioni(match.valutazioni, g.id, set); + righe.push( + rigaCsv([ + set, + g.numero, + g.nome, + ...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]), + ]), + ); + } + } + } righe.push(""); righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); for (const a of match.azioni) { diff --git a/src/lib/scout-stato.ts b/src/lib/scout-stato.ts index 10d568e..785ec61 100644 --- a/src/lib/scout-stato.ts +++ b/src/lib/scout-stato.ts @@ -1,10 +1,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import type { Azione } from "./scout-store"; +import type { Azione, Valutazione } from "./scout-store"; /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */ export type StatoScout = { azioni: Azione[]; + /** Assente negli stati salvati prima dell'introduzione delle valutazioni. */ + valutazioni?: Valutazione[]; setChiusi: Array<[number, number]>; avversario: string; casa: boolean; @@ -12,6 +14,7 @@ export type StatoScout = { export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({ azioni: [], + valutazioni: [], setChiusi: [], avversario, casa, diff --git a/src/lib/scout-store.ts b/src/lib/scout-store.ts index b89638f..d54087c 100644 --- a/src/lib/scout-store.ts +++ b/src/lib/scout-store.ts @@ -60,6 +60,42 @@ export type Azione = { ts: number; }; +/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */ +export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro"; + +export const fondamentali: Array<{ id: Fondamentale; label: string }> = [ + { id: "battuta", label: "Battuta" }, + { id: "ricezione", label: "Ricezione" }, + { id: "attacco", label: "Attacco" }, + { id: "muro", label: "Muro" }, +]; + +export type Valutazione = { + id: string; + giocatoreId: string; + fondamentale: Fondamentale; + segno: "+" | "-"; + set: number; + ts: number; +}; + +export type ConteggioValutazioni = Record; + +/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */ +export function contaValutazioni( + valutazioni: Valutazione[], + giocatoreId: string, + set?: number, +): ConteggioValutazioni { + const out = {} as ConteggioValutazioni; + for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 }; + for (const v of valutazioni) { + if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue; + out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1; + } + return out; +} + export type ScoutMatch = { id: string; data: string; @@ -69,6 +105,7 @@ export type ScoutMatch = { setLoro: number; parziali: Array<[number, number]>; azioni: Azione[]; + valutazioni: Valutazione[]; }; type RigaScoutPartita = { @@ -80,6 +117,7 @@ type RigaScoutPartita = { set_loro: number; parziali: unknown; azioni: unknown; + valutazioni: unknown; }; function daRiga(r: RigaScoutPartita): ScoutMatch { @@ -92,6 +130,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch { setLoro: r.set_loro, parziali: (r.parziali as Array<[number, number]> | null) ?? [], azioni: (r.azioni as Azione[] | null) ?? [], + valutazioni: (r.valutazioni as Valutazione[] | null) ?? [], }; } @@ -100,7 +139,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const; async function fetchScoutMatches(): Promise { const { data, error } = await supabase .from("scout_partite") - .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni") + .select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni") .order("creato_il", { ascending: false }); if (error) throw error; return (data ?? []).map(daRiga); @@ -131,6 +170,7 @@ export function useSalvaScoutMatch() { set_loro: input.match.setLoro, parziali: JSON.parse(JSON.stringify(input.match.parziali)), azioni: JSON.parse(JSON.stringify(input.match.azioni)), + valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)), }); if (error) throw error; return input.match; diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 36f9711..3e1c17b 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -3,7 +3,6 @@ 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 { useAnagraficaRosa } from "@/lib/rosa"; @@ -27,10 +26,14 @@ import { } from "@/lib/scout-live"; import { azioniMeta, + contaValutazioni, + fondamentali, useSalvaScoutMatch, totaliPerGiocatore, type Azione, type AzioneTipo, + type Fondamentale, + type Valutazione, } from "@/lib/scout-store"; export const Route = createFileRoute("/scout")({ @@ -52,8 +55,6 @@ export const Route = createFileRoute("/scout")({ component: Scout, }); -const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"]; - function Blocco({ icona, titolo, @@ -196,10 +197,11 @@ function ScoutBoard({ .trim() || "Avversario", partita.titolo.trim().toLowerCase().startsWith("crap"), ); - const [avversario, setAvversario] = useState(base.avversario); - const [casa, setCasa] = useState(base.casa); + 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 [selezionato, setSelezionato] = useState(null); const salva = useSalvaStatoScout(); const cancella = useCancellaStatoScout(); @@ -223,37 +225,44 @@ function ScoutBoard({ const id = window.setTimeout(() => { void salva.mutateAsync({ eventoId: partita.id, - stato: { azioni, setChiusi, avversario, casa }, + stato: { azioni, valutazioni, setChiusi, avversario, casa }, }); }, 800); return () => window.clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [azioni, setChiusi, avversario, casa, partita.id]); + }, [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 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) { + function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) { + if (!selezionato) { toast.error("Seleziona prima il giocatore"); return; } - setAzioni((prev) => { - const nuova: Azione = { + setValutazioni((prev) => [ + ...prev, + { id: `${Date.now()}-${prev.length}`, - tipo, + giocatoreId: selezionato, + fondamentale, + segno, set: setCorrente, ts: Date.now(), - ...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}), - }; - return [...prev, nuova]; - }); + }, + ]); + } + + 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() { @@ -294,6 +303,7 @@ function ScoutBoard({ setLoro: parziali.length - vinti, parziali, azioni, + valutazioni, }, }); } catch { @@ -307,6 +317,10 @@ function ScoutBoard({ navigate({ to: "/squadra" }); } + const giocatoreSel = rosa.find((g) => g.id === selezionato); + const contiSelezionato = selezionato + ? contaValutazioni(valutazioni, selezionato, setCorrente) + : null; const ultime = [...azioni].slice(-4).reverse(); return ( @@ -334,35 +348,6 @@ function ScoutBoard({ - -
-
-

CRAP Volley

-

{puntiNoi}

-
-

- {setNostri}-{setLoro} -

-
-

- {avversario || "Avversario"} -

-

{puntiLoro}

-
-
- - {setChiusi.length > 0 ? ( -
- {setChiusi.map((p, i) => ( - - {p[0]}-{p[1]} - - ))} -
- ) : null}
@@ -400,39 +385,55 @@ function ScoutBoard({
-

- 2. Tocca l'azione -

-
- {ordineAzioni.map((tipo) => ( - - ))} +
+

+ 2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "} + {setCorrente} +

+
-
- {(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => ( - - ))} +
+ {!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} + + +
+ ); + })}
@@ -472,35 +473,6 @@ function ScoutBoard({ -
- - - setAvversario(e.target.value)} - className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]" - /> -
- {[true, false].map((v) => ( - - ))} -
-
-
-