Scout live: valutazioni − / + per fondamentale e per set

Per ogni convocato un − o un + su battuta, ricezione, attacco e muro, separato per set e
fuori dal punteggio (DD-053, migration m39: colonna scout_partite.valutazioni). Lo stato in
corso le salva in scout_live.stato; il CSV admin ha la tabella per set.

Nella pagina dello scout tolti il blocco avversario/campo, i tasti delle azioni e il
tabellone del punteggio; il titolo della sezione mostra cognome e numero del giocatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5.5 committed 2026-11-02 08:16:39 +01:00
1 parent 09dba1890d
commit 51b7a10c99
16 files changed
+367 -120

No files matched your search

+7 -1
View File
@@ -13,7 +13,13 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [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 ### Modificato
+1 -1
View File
@@ -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_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_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 ## Votazioni
+33
View File
@@ -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.
+1
View File
@@ -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-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-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-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-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard | | [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno | | [DD-047](DD-047.md) | Notifica di compleanno |
+24 -4
View File
@@ -10,7 +10,8 @@
## Obiettivo ## Obiettivo
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita, 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 Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
aggiornato da qualunque dispositivo. 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 - `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
`evento_id`, quindi un solo detentore). `evento_id`, quindi un solo detentore).
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione. - `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). mai più modificato una volta salvato (solo eliminabile per intero).
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento - `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
(`UNIQUE evento_id, giocatore_id`). (`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 ## Fine partita
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in `finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
@@ -85,8 +102,10 @@ e rilascia la sessione.
## Export CSV ## Export CSV
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per `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, giocatore e log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con i
sezione "Report tecnico".
- 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 ## 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. - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
+3
View File
@@ -536,6 +536,7 @@ export type Database = {
parziali: Json; parziali: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni: Json;
}; };
Insert: { Insert: {
avversario: string; avversario: string;
@@ -548,6 +549,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro: number; set_loro: number;
set_nostri: number; set_nostri: number;
valutazioni?: Json;
}; };
Update: { Update: {
avversario?: string; avversario?: string;
@@ -560,6 +562,7 @@ export type Database = {
parziali?: Json; parziali?: Json;
set_loro?: number; set_loro?: number;
set_nostri?: number; set_nostri?: number;
valutazioni?: Json;
}; };
Relationships: []; Relationships: [];
}; };
+2 -1
View File
@@ -34,7 +34,7 @@ function iniziali(nome: string, cognome: string): string {
export function useAnagraficaRosa({ export function useAnagraficaRosa({
conAllenatori = false, conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array< }: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
> { > {
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
return useMemo( return useMemo(
@@ -44,6 +44,7 @@ export function useAnagraficaRosa({
.map((g) => ({ .map((g) => ({
id: g.id, id: g.id,
nome: nomeCompleto(g), nome: nomeCompleto(g),
cognome: g.cognome,
ruolo: g.ruolo, ruolo: g.ruolo,
numero: g.numero, numero: g.numero,
nascita: g.nascita ?? "", nascita: g.nascita ?? "",
+33 -1
View File
@@ -1,5 +1,11 @@
import type { Giocatore } from "./crapp-data"; 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. */ /** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
export function rigaCsv(campi: Array<string | number>) { export function rigaCsv(campi: Array<string | number>) {
@@ -34,6 +40,32 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
if (!t) continue; if (!t) continue;
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori])); 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("");
righe.push(rigaCsv(["Set", "Giocatore", "Azione"])); righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
for (const a of match.azioni) { for (const a of match.azioni) {
+4 -1
View File
@@ -1,10 +1,12 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; 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. */ /** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
export type StatoScout = { export type StatoScout = {
azioni: Azione[]; azioni: Azione[];
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
valutazioni?: Valutazione[];
setChiusi: Array<[number, number]>; setChiusi: Array<[number, number]>;
avversario: string; avversario: string;
casa: boolean; casa: boolean;
@@ -12,6 +14,7 @@ export type StatoScout = {
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({ export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
azioni: [], azioni: [],
valutazioni: [],
setChiusi: [], setChiusi: [],
avversario, avversario,
casa, casa,
+41 -1
View File
@@ -60,6 +60,42 @@ export type Azione = {
ts: number; 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<Fondamentale, { piu: number; meno: number }>;
/** 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 = { export type ScoutMatch = {
id: string; id: string;
data: string; data: string;
@@ -69,6 +105,7 @@ export type ScoutMatch = {
setLoro: number; setLoro: number;
parziali: Array<[number, number]>; parziali: Array<[number, number]>;
azioni: Azione[]; azioni: Azione[];
valutazioni: Valutazione[];
}; };
type RigaScoutPartita = { type RigaScoutPartita = {
@@ -80,6 +117,7 @@ type RigaScoutPartita = {
set_loro: number; set_loro: number;
parziali: unknown; parziali: unknown;
azioni: unknown; azioni: unknown;
valutazioni: unknown;
}; };
function daRiga(r: RigaScoutPartita): ScoutMatch { function daRiga(r: RigaScoutPartita): ScoutMatch {
@@ -92,6 +130,7 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
setLoro: r.set_loro, setLoro: r.set_loro,
parziali: (r.parziali as Array<[number, number]> | null) ?? [], parziali: (r.parziali as Array<[number, number]> | null) ?? [],
azioni: (r.azioni as Azione[] | 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<ScoutMatch[]> { async function fetchScoutMatches(): Promise<ScoutMatch[]> {
const { data, error } = await supabase const { data, error } = await supabase
.from("scout_partite") .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 }); .order("creato_il", { ascending: false });
if (error) throw error; if (error) throw error;
return (data ?? []).map(daRiga); return (data ?? []).map(daRiga);
@@ -131,6 +170,7 @@ export function useSalvaScoutMatch() {
set_loro: input.match.setLoro, set_loro: input.match.setLoro,
parziali: JSON.parse(JSON.stringify(input.match.parziali)), parziali: JSON.parse(JSON.stringify(input.match.parziali)),
azioni: JSON.parse(JSON.stringify(input.match.azioni)), azioni: JSON.parse(JSON.stringify(input.match.azioni)),
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
}); });
if (error) throw error; if (error) throw error;
return input.match; return input.match;
+81 -109
View File
@@ -3,7 +3,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
@@ -27,10 +26,14 @@ import {
} from "@/lib/scout-live"; } from "@/lib/scout-live";
import { import {
azioniMeta, azioniMeta,
contaValutazioni,
fondamentali,
useSalvaScoutMatch, useSalvaScoutMatch,
totaliPerGiocatore, totaliPerGiocatore,
type Azione, type Azione,
type AzioneTipo, type AzioneTipo,
type Fondamentale,
type Valutazione,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
export const Route = createFileRoute("/scout")({ export const Route = createFileRoute("/scout")({
@@ -52,8 +55,6 @@ export const Route = createFileRoute("/scout")({
component: Scout, component: Scout,
}); });
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
function Blocco({ function Blocco({
icona, icona,
titolo, titolo,
@@ -196,10 +197,11 @@ function ScoutBoard({
.trim() || "Avversario", .trim() || "Avversario",
partita.titolo.trim().toLowerCase().startsWith("crap"), partita.titolo.trim().toLowerCase().startsWith("crap"),
); );
const [avversario, setAvversario] = useState(base.avversario); const [avversario] = useState(base.avversario);
const [casa, setCasa] = useState(base.casa); const [casa] = useState(base.casa);
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi); const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
const [azioni, setAzioni] = useState<Azione[]>(base.azioni); const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
const [valutazioni, setValutazioni] = useState<Valutazione[]>(base.valutazioni ?? []);
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();
@@ -223,37 +225,44 @@ function ScoutBoard({
const id = window.setTimeout(() => { const id = window.setTimeout(() => {
void salva.mutateAsync({ void salva.mutateAsync({
eventoId: partita.id, eventoId: partita.id,
stato: { azioni, setChiusi, avversario, casa }, stato: { azioni, valutazioni, setChiusi, avversario, casa },
}); });
}, 800); }, 800);
return () => window.clearTimeout(id); return () => window.clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 setCorrente = setChiusi.length + 1;
const azioniSet = azioni.filter((a) => a.set === setCorrente); const azioniSet = azioni.filter((a) => a.set === setCorrente);
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
const puntiLoro = azioniSet.length - puntiNoi; 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]); const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
function registra(tipo: AzioneTipo) { function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
const meta = azioniMeta[tipo]; if (!selezionato) {
if (meta.richiedeGiocatore && !selezionato) {
toast.error("Seleziona prima il giocatore"); toast.error("Seleziona prima il giocatore");
return; return;
} }
setAzioni((prev) => { setValutazioni((prev) => [
const nuova: Azione = { ...prev,
{
id: `${Date.now()}-${prev.length}`, id: `${Date.now()}-${prev.length}`,
tipo, giocatoreId: selezionato,
fondamentale,
segno,
set: setCorrente, set: setCorrente,
ts: Date.now(), 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() { function annulla() {
@@ -294,6 +303,7 @@ function ScoutBoard({
setLoro: parziali.length - vinti, setLoro: parziali.length - vinti,
parziali, parziali,
azioni, azioni,
valutazioni,
}, },
}); });
} catch { } catch {
@@ -307,6 +317,10 @@ function ScoutBoard({
navigate({ to: "/squadra" }); 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(); const ultime = [...azioni].slice(-4).reverse();
return ( return (
@@ -334,35 +348,6 @@ function ScoutBoard({
</button> </button>
</div> </div>
</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-[13px] 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-[13px] 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-[13px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
))}
</div>
) : null}
</header> </header>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
@@ -400,39 +385,55 @@ function ScoutBoard({
</section> </section>
<section className="px-5 pt-4"> <section className="px-5 pt-4">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground"> <div className="mb-2 flex items-center justify-between gap-2">
2. Tocca l'azione <p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
</p> 2. Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
<div className="grid grid-cols-2 gap-2"> {setCorrente}
{ordineAzioni.map((tipo) => ( </p>
<button <button
key={tipo} type="button"
type="button" onClick={annullaValutazione}
onClick={() => registra(tipo)} disabled={!valutazioni.some((v) => v.set === setCorrente)}
className={cn( className="inline-flex shrink-0 items-center gap-1 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95", >
azioniMeta[tipo].className, <Undo2 className="h-3.5 w-3.5" /> Annulla
!selezionato && "opacity-50", </button>
)}
>
{azioniMeta[tipo].label}
</button>
))}
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2"> <div
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => ( className={cn(
<button "space-y-2 rounded-3xl bg-card p-3 shadow-card",
key={tipo} !selezionato && "opacity-60",
type="button" )}
onClick={() => registra(tipo)} >
className={cn( {!selezionato ? (
"rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95", <p className="text-[13px] text-muted-foreground">
azioniMeta[tipo].className, Tocca un giocatore: i + e i − non cambiano il punteggio.
)} </p>
> ) : null}
{azioniMeta[tipo].short} {fondamentali.map((f) => {
</button> const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 };
))} return (
<div key={f.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-2">
<span className="text-[15px] font-bold">{f.label}</span>
<button
type="button"
aria-label={`${f.label}: meno`}
onClick={() => valuta(f.id, "-")}
className="min-w-16 rounded-2xl bg-destructive px-4 py-2.5 font-display text-xl leading-none text-destructive-foreground shadow-card transition-transform active:scale-95"
>
− <span className="tabular-nums">{c.meno}</span>
</button>
<button
type="button"
aria-label={`${f.label}: più`}
onClick={() => valuta(f.id, "+")}
className="min-w-16 rounded-2xl bg-success px-4 py-2.5 font-display text-xl leading-none text-success-foreground shadow-card transition-transform active:scale-95"
>
+ <span className="tabular-nums">{c.piu}</span>
</button>
</div>
);
})}
</div> </div>
</section> </section>
@@ -472,35 +473,6 @@ function ScoutBoard({
</div> </div>
</section> </section>
<section className="px-5 pt-4">
<Card>
<label className="text-[13px] 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-[15px]"
/>
<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-[13px] 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"> <section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
<button <button
type="button" type="button"
@@ -0,0 +1,10 @@
-- M39 — Scout Live: valutazioni − / + per fondamentale.
--
-- Durante la partita lo scout può segnare, per ogni giocatore e per ogni set, un − o un +
-- su battuta, ricezione, attacco e muro. Non sono punti: restano fuori dal punteggio e da
-- `azioni`. Lo stato in corso le porta dentro `scout_live.stato` (jsonb, nessuna modifica);
-- la partita archiviata le conserva in questa colonna, accanto alle azioni.
-- Solo additiva: le partite già salvate ricevono l'elenco vuoto.
ALTER TABLE public.scout_partite
ADD COLUMN valutazioni jsonb NOT NULL DEFAULT '[]'::jsonb;
+27
View File
@@ -32,6 +32,7 @@ if (!locale) {
"turni_palloni", "turni_palloni",
"risposte_presenze", "risposte_presenze",
"scout_live", "scout_live",
"scout_partite",
] as const; ] as const;
const rest = (percorso: string, init?: RequestInit) => const rest = (percorso: string, init?: RequestInit) =>
@@ -312,6 +313,32 @@ if (!locale) {
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi", "il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
); );
}); });
// --- scout: le valutazioni − / + stanno accanto alle azioni (m39) ----------------
await prova(
"la partita scoutata conserva le valutazioni; senza, l'elenco è vuoto",
async () => {
const evento = `${PREFISSO}-e5`;
const base = {
evento_id: evento,
data: "2026-11-02",
avversario: "X",
set_nostri: 3,
set_loro: 0,
};
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s1` });
const valutazioni = [
{ id: "v1", giocatoreId: "g1", fondamentale: "battuta", segno: "+", set: 1, ts: 0 },
];
await upsert("scout_partite", "id", { ...base, id: `${PREFISSO}-s2`, valutazioni });
const righe = await leggi(
"scout_partite",
`evento_id=eq.${evento}&select=id,valutazioni&order=id`,
);
assert.deepEqual(righe[0]?.["valutazioni"], [], "le partite senza valutazioni hanno []");
assert.deepEqual(righe[1]?.["valutazioni"], valutazioni);
},
);
} finally { } finally {
for (const tabella of TABELLE) { for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id"; const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
+40
View File
@@ -29,6 +29,7 @@ const match: ScoutMatch = {
[25, 18], [25, 18],
], ],
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")],
valutazioni: [],
}; };
const csv = csvScoutMatch(match, giocatori); const csv = csvScoutMatch(match, giocatori);
@@ -78,3 +79,42 @@ const conVirgolette = csvScoutMatch({ ...match, avversario: 'Team "X"' }, giocat
assert.ok(conVirgolette.includes('"Team ""X"""')); assert.ok(conVirgolette.includes('"Team ""X"""'));
console.log("scout-export: ok"); console.log("scout-export: ok");
// --- valutazioni − / + per set -------------------------------------------------
assert.ok(!csv.includes("Battuta +"), "senza valutazioni la sezione non c'è");
const val = (
giocatoreId: string,
fondamentale: "battuta" | "muro",
segno: "+" | "-",
set: number,
) => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
const conVal = csvScoutMatch(
{
...match,
valutazioni: [
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "+", 1),
val(g1.id, "battuta", "-", 1),
val(g1.id, "muro", "-", 2),
],
},
giocatori,
);
assert.ok(
conVal.includes(
"Set;Numero;Giocatore;Battuta +;Battuta -;Ricezione +;Ricezione -;Attacco +;Attacco -;Muro +;Muro -",
),
);
assert.ok(
conVal.includes(`1;${g1.numero};${g1.nome};2;1;0;0;0;0;0;0`),
"set 1: 2 più e 1 meno in battuta",
);
assert.ok(conVal.includes(`2;${g1.numero};${g1.nome};0;0;0;0;0;0;0;1`), "set 2: 1 meno a muro");
assert.ok(!conVal.includes(`;${g2.nome};0;0;0;0;0;0;0;0`), "chi non ha valutazioni non ha riga");
+5 -1
View File
@@ -5,7 +5,10 @@ import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati ----------- // --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
const stato = statoIniziale("Avversari", true); const stato = statoIniziale("Avversari", true);
// Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`. // Confronta una copia: deepEqual restringerebbe `stato` al letterale, con `azioni: never[]`.
assert.deepEqual({ ...stato }, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true }); assert.deepEqual(
{ ...stato },
{ azioni: [], valutazioni: [], setChiusi: [], avversario: "Avversari", casa: true },
);
const trasferta = statoIniziale("Altra Squadra", false); const trasferta = statoIniziale("Altra Squadra", false);
assert.equal(trasferta.casa, false); assert.equal(trasferta.casa, false);
@@ -14,6 +17,7 @@ assert.deepEqual(trasferta.azioni, [], "ogni chiamata parte da una lista vuota i
// Le liste non devono essere condivise tra due stati distinti. // Le liste non devono essere condivise tra due stati distinti.
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 }); stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro"); assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro");
assert.notEqual(stato.valutazioni, trasferta.valutazioni, "anche le valutazioni sono indipendenti");
// --- SCOUT_STATO_KEY: chiave stabile per React Query -------------------------- // --- SCOUT_STATO_KEY: chiave stabile per React Query --------------------------
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]); assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
+55
View File
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data"; import { giocatori } from "@/lib/crapp-data";
import { import {
azioniMeta, azioniMeta,
contaValutazioni,
fondamentali,
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
type Azione, type Azione,
type Valutazione,
type ScoutMatch, type ScoutMatch,
} from "@/lib/scout-store"; } from "@/lib/scout-store";
@@ -32,6 +35,7 @@ const match = (
setLoro, setLoro,
parziali: [], parziali: [],
azioni, azioni,
valutazioni: [],
}); });
// --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------ // --- totaliPerGiocatore: ace e muri valgono anche come punto ------------------
@@ -84,3 +88,54 @@ for (const [tipo, meta] of Object.entries(azioniMeta)) {
} }
console.log("scout-store: ok"); console.log("scout-store: ok");
// --- valutazioni − / +: per giocatore, fondamentale e set; fuori dal punteggio -------
const v = (
giocatoreId: string,
fondamentale: Valutazione["fondamentale"],
segno: Valutazione["segno"],
set = 1,
): Valutazione => ({
id: `${giocatoreId}-${fondamentale}-${segno}-${set}-${Math.random()}`,
giocatoreId,
fondamentale,
segno,
set,
ts: 0,
});
assert.deepEqual(
fondamentali.map((f) => f.id),
["battuta", "ricezione", "attacco", "muro"],
);
const val = [
v("g1", "battuta", "+"),
v("g1", "battuta", "+"),
v("g1", "battuta", "-"),
v("g1", "ricezione", "-", 2),
v("g1", "muro", "+", 2),
v("g2", "attacco", "+"),
];
assert.deepEqual(contaValutazioni(val, "g1", 1), {
battuta: { piu: 2, meno: 1 },
ricezione: { piu: 0, meno: 0 },
attacco: { piu: 0, meno: 0 },
muro: { piu: 0, meno: 0 },
});
assert.deepEqual(
contaValutazioni(val, "g1", 2).ricezione,
{ piu: 0, meno: 1 },
"il set 2 è separato",
);
assert.deepEqual(
contaValutazioni(val, "g1").battuta,
{ piu: 2, meno: 1 },
"senza set: tutta la partita",
);
assert.deepEqual(contaValutazioni(val, "g1").ricezione, { piu: 0, meno: 1 });
assert.deepEqual(
contaValutazioni(val, "g3", 1).battuta,
{ piu: 0, meno: 0 },
"giocatore senza valutazioni",
);
assert.equal(totaliPerGiocatore([]).size, 0, "le valutazioni non sono azioni: nessun punto");