Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5281c0c62b | ||
|
|
3a892a78e6 | ||
|
|
1e4ec9113e | ||
|
|
263621456d | ||
|
|
7b93f92965 | ||
|
|
c98e1ef218 | ||
|
|
e6cfb1bf00 | ||
|
|
30fb8e23b6 | ||
|
|
91ac7ce65c |
No files matched your search
+5
-1
@@ -9,7 +9,11 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
||||
|
||||
## Stato generale
|
||||
|
||||
- **Versione:** 1.6.0 su `main` (tag `v1.6.0`), pubblicata il 02/11/2026: voto MVP definitivo, segreto e a
|
||||
- **Versione:** 1.6.2 su `main`: layout corretto sugli schermi stretti (360-402 px) e
|
||||
Ace, Attacchi, Muri e Punti dai + dello scout in Squadra e Profilo, senza migration. La 1.6.1
|
||||
(tag `v1.6.1`): risultati dello scout leggibili nella pagina partita, classifica cacche
|
||||
completa e pagina partita a sezioni a scomparsa. La 1.6.0
|
||||
(tag `v1.6.0`, in produzione dal 02/11/2026): voto MVP definitivo, segreto e a
|
||||
tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
|
||||
nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
|
||||
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
|
||||
|
||||
@@ -13,6 +13,50 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.6.2] - 2026-11-11
|
||||
|
||||
Layout corretto sugli schermi stretti e statistiche tecniche dai + dello scout in Squadra e
|
||||
Profilo. Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Squadra → Stats: «Punti squadra», «Ace squadra» e «Muri squadra» sommano i + dello scout di
|
||||
tutte le partite (ace = + di battuta, muri = + di muro, punti = + di attacco, battuta e muro);
|
||||
le partite del metodo precedente contano con le loro azioni.
|
||||
- Profilo → Season: al posto di «Turni palloni» compaiono Ace, Attacchi, Muri e Punti (totale)
|
||||
del giocatore, somma dei + dello scout di tutte le partite (DD-008: solo informativi).
|
||||
- Layout su schermi stretti (360-402 px, iPhone e Android): niente più pagina tagliata in
|
||||
larghezza. La tabella per giocatore dello scout sta nello schermo (il nome va a capo su due righe), le
|
||||
griglie di riquadri non sfondano con nomi ed etichette lunghe, la pagina non scorre in
|
||||
orizzontale e i campi di testo sono a 16 px, così iOS non zooma al tocco. I nomi non si spezzano
|
||||
mai a metà parola; nello scout live i riquadri dei giocatori sono a 3 colonne sotto i 400 px.
|
||||
|
||||
## [1.6.1] - 2026-11-06
|
||||
|
||||
Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni
|
||||
a scomparsa. Nessuna migration.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Pagina partita, sezione «Scout»: i risultati dello scout si leggono nell'app, non solo nel CSV
|
||||
admin. Per ogni fondamentale: percentuale di +, barra verde/rossa e conteggi di squadra; selettore
|
||||
«Totale» e «Set 1»…«Set 5» e tabella per giocatore con + / −. Il pulsante ⓘ apre una guida su come
|
||||
leggere i dati. Le azioni (punti, ace, muri, errori) restano in un blocco a parte per le partite
|
||||
scoutate col metodo precedente; il CSV resta per gli admin.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Sondaggio cacche: la classifica completa è visibile a tutti i giocatori (non più solo il podio),
|
||||
anche a sondaggio chiuso, con posizioni a pari merito senza salti (1°, 1°, 2°), barra
|
||||
proporzionale e la propria riga evidenziata.
|
||||
- Pagina partita: formazioni, badge, sondaggio, pagelle, scout e rosa sono sezioni a scomparsa
|
||||
(aperti all'inizio solo rosa e presenze e, nella sua finestra, il sondaggio). Le formazioni
|
||||
indicano la fonte «Livescore CSI»; lo scout mostra il numero di valutazioni. I titoli delle
|
||||
sezioni a scomparsa hanno la stessa dimensione delle altre e le chiuse non lasciano vuoto sotto.
|
||||
- Pagelle di fine partita: non compaiono più i convocati che risultano assenti o infortunati a
|
||||
quella partita (solo filtro di interfaccia).
|
||||
- Pagina partita: la sezione «Report tecnico» si chiama «Scout».
|
||||
|
||||
## [1.6.0] - 2026-11-02
|
||||
|
||||
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
|
||||
|
||||
@@ -17,6 +17,9 @@ Le statistiche **personali** in profilo e squadra devono essere **eque per tutti
|
||||
|
||||
- Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
|
||||
- Lo scout resta strumento tecnico, non gioco.
|
||||
- Aggiornamento 2026-11-11: in Profilo → Season compaiono, come semplice informazione personale,
|
||||
i totali di ace, attacchi, muri e punti del giocatore ricavati dai + dello scout. Non entrano
|
||||
in badge, obiettivi o classifiche: la regola sulla leva competitiva resta valida.
|
||||
|
||||
**Riesame**
|
||||
Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
|
||||
@@ -26,6 +26,8 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
|
||||
partita (scout salvato o dato CSI).
|
||||
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
|
||||
dal vincolo DB.
|
||||
- Nell'elenco da votare non compaiono i convocati che hanno risposto «assente» o «infortunato»
|
||||
alle presenze: solo filtro di interfaccia in `partita.$id.tsx`, il vincolo DB non cambia.
|
||||
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
|
||||
volte, l'ultimo voto sovrascrive il precedente.
|
||||
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
|
||||
|
||||
@@ -285,8 +285,16 @@ Sezione già presente. Contiene.
|
||||
- Voto medio
|
||||
- MVP
|
||||
- Serie
|
||||
- **Ace, Attacchi, Muri e Punti (totale)**: somma, su tutte le partite scoutate, dei soli **+**
|
||||
assegnati a quel giocatore nello scout live (ace = + di battuta, attacchi = + di attacco,
|
||||
muri = + di muro; punti = ace + attacchi + muri, la ricezione non conta). Le partite scoutate
|
||||
col metodo precedente, senza valutazioni, contano con le azioni registrate; se una partita ha
|
||||
entrambe, valgono solo le valutazioni (`totaliTecnici()` in `scout-store.ts`). Il «Turni
|
||||
palloni» non compare più qui: il turno resta nel banner palloni e nel badge dedicato.
|
||||
- Altre statistiche disponibili
|
||||
|
||||
Sono numeri informativi: non alimentano badge, obiettivi né classifiche (DD-008).
|
||||
|
||||
### Badge
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
@@ -109,7 +109,7 @@ Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutaz
|
||||
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde
|
||||
«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**:
|
||||
`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
|
||||
referto CSI; resta visibile il «Report tecnico» con il CSV.
|
||||
referto CSI; resta visibile il «Scout» con il CSV.
|
||||
|
||||
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
|
||||
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
|
||||
@@ -122,7 +122,18 @@ e rilascia la sessione.
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per giocatore e
|
||||
log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
|
||||
i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
|
||||
pagina partita, sezione "Report tecnico".
|
||||
pagina partita, sezione "Scout".
|
||||
|
||||
---
|
||||
|
||||
## Lettura nell'app
|
||||
|
||||
La sezione «Scout» della pagina partita (`ScoutReport.tsx`, visibile a chi vede la pagina; il CSV
|
||||
resta per i soli admin) mostra, per la partita o per un singolo set (selettore in alto, se i set
|
||||
sono più di uno): per ciascun fondamentale la percentuale di **+**, una barra verde/rossa e i
|
||||
conteggi di squadra; poi la tabella per giocatore con **+ / −** di ogni fondamentale. I totali
|
||||
vengono da `sintesiValutazioni()` (`scout-store.ts`). Le azioni (P/A/M/E) compaiono in un blocco a
|
||||
parte solo se la partita ne ha, cioè quelle scoutate prima di DD-053. Il pulsante (icona i) apre una guida in linea su valutazioni, percentuale, tabella e set.
|
||||
|
||||
---
|
||||
|
||||
@@ -153,11 +164,8 @@ calcolati a runtime).
|
||||
|
||||
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita»
|
||||
entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
|
||||
- Le valutazioni dei set chiusi e delle partite finite sono salvate ma non si rivedono nell'app: lo
|
||||
scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella
|
||||
pagina partita.
|
||||
- Il blocco «Report tecnico» della pagina partita mostra ancora Punti/Ace/Muri/Errori (a zero per le
|
||||
partite di sole valutazioni) e non le valutazioni, che sono solo nel CSV admin.
|
||||
- Lo scout in corso mostra solo il set in corso: le valutazioni dei set chiusi si rivedono solo
|
||||
nella pagina partita, dopo «Fine partita».
|
||||
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
|
||||
garanzia è il lock di sessione (vedi sopra).
|
||||
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
|
||||
@@ -171,6 +179,5 @@ calcolati a runtime).
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin)
|
||||
e, nello scout, in sola lettura quelle dei set chiusi.
|
||||
- Nello scout, in sola lettura, le valutazioni dei set chiusi.
|
||||
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
|
||||
@@ -82,6 +82,13 @@ successivo non salta — 1, 1, 2, non 1, 1, 3); `dettaglioClassifica()` sceglie
|
||||
sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
|
||||
pagella" per il criterio media voto, non sempre "presenze consecutive").
|
||||
|
||||
In cima alla tab, i riquadri «Punti squadra», «Ace squadra» e «Muri squadra» sommano **tutte le
|
||||
partite scoutate** (`totaliSquadraValutazioni()`, che usa `totaliTecnici()`, in `scout-store.ts`), contando solo le valutazioni
|
||||
**+** dello scout live: «Ace» = + di battuta, «Muri» = + di muro, «Punti» = + di attacco, battuta e
|
||||
muro (la ricezione non è un punto messo a terra, come nel riquadro del giocatore). Le partite
|
||||
scoutate col metodo precedente, senza valutazioni, contano con le loro azioni registrate (punti,
|
||||
ace, muri); se una partita ha entrambe, valgono solo le valutazioni, così non si conta due volte.
|
||||
|
||||
Le altre tab di `/squadra` (Rosa, Obiettivi, Badge) sono viste diverse sugli stessi dati di
|
||||
`useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
|
||||
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "crapp",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.2",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import { BarChart3, Download, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import {
|
||||
fondamentali,
|
||||
sintesiValutazioni,
|
||||
totaliPerGiocatore,
|
||||
totaliSquadra,
|
||||
type ScoutMatch,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
const pct = (piu: number, meno: number) =>
|
||||
piu + meno === 0 ? null : Math.round((piu / (piu + meno)) * 100);
|
||||
|
||||
/** Lettura dello scout di una partita: squadra per fondamentale, set per set, dettaglio per giocatore. */
|
||||
export function ScoutReport({
|
||||
scout,
|
||||
rosa,
|
||||
admin,
|
||||
}: {
|
||||
scout: ScoutMatch;
|
||||
rosa: Giocatore[];
|
||||
admin: boolean;
|
||||
}) {
|
||||
// undefined = tutta la partita.
|
||||
const [set, setSet] = useState<number | undefined>(undefined);
|
||||
const [guida, setGuida] = useState(false);
|
||||
const sets = [...new Set(scout.valutazioni.map((v) => v.set))].sort((a, b) => a - b);
|
||||
const { squadra, perGiocatore } = sintesiValutazioni(scout.valutazioni, set);
|
||||
const righe = perGiocatore
|
||||
.map((r) => ({ ...r, g: rosa.find((x) => x.id === r.giocatoreId) }))
|
||||
.filter((r): r is typeof r & { g: Giocatore } => !!r.g)
|
||||
.sort((a, b) => a.g.numero - b.g.numero);
|
||||
|
||||
const azioni = totaliPerGiocatore(scout.azioni);
|
||||
const totaliAzioni = totaliSquadra([scout]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<BarChart3 className="h-3.5 w-3.5" /> Valutazione scout
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={guida}
|
||||
aria-label="Come leggere lo scout"
|
||||
onClick={() => setGuida((g) => !g)}
|
||||
className={cn(
|
||||
"premi flex h-8 w-8 items-center justify-center rounded-full transition-colors",
|
||||
guida ? "bg-primary text-primary-foreground" : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{guida ? (
|
||||
<dl className="mt-3 space-y-3 rounded-2xl bg-secondary p-4 text-[13px] leading-relaxed">
|
||||
{[
|
||||
[
|
||||
"Cosa misura",
|
||||
<>
|
||||
Durante la partita lo scout assegna a ciascun giocatore una valutazione{" "}
|
||||
<b className="text-success">+</b> (esecuzione positiva) o{" "}
|
||||
<b className="text-destructive">−</b> (esecuzione negativa) su quattro fondamentali:
|
||||
battuta, ricezione, attacco e muro. Sono giudizi qualitativi, non punti: non
|
||||
incidono su risultato, classifiche o badge.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Indicatori di squadra",
|
||||
<>
|
||||
Ogni scheda riporta, per fondamentale, la percentuale di valutazioni positive (+ ÷
|
||||
(+ + −)), la barra di composizione verde/rossa e il numero di + e −. Un valore alto
|
||||
indica un fondamentale eseguito con continuità.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Dettaglio per giocatore",
|
||||
<>
|
||||
La tabella elenca, per ogni giocatore, i + e i − di ciascun fondamentale nel formato{" "}
|
||||
<b>+ / −</b>: «3 / 1» indica tre valutazioni positive e una negativa. Compaiono solo
|
||||
i giocatori con almeno una valutazione nel periodo selezionato.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Periodo",
|
||||
<>
|
||||
«Totale» aggrega l'intera partita; «Set 1», «Set 2»… isolano un singolo set e
|
||||
permettono di osservare l'andamento durante la gara.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Interpretazione",
|
||||
<>
|
||||
Il campione è ridotto: con pochi tocchi la percentuale è poco significativa (un solo
|
||||
+ su un solo tocco vale 100%). I dati riflettono il giudizio dello scout in servizio
|
||||
e vanno letti come indicazione di squadra, non come voto individuale.
|
||||
</>,
|
||||
],
|
||||
[
|
||||
"Partite precedenti",
|
||||
<>
|
||||
Per le partite scoutate con il metodo precedente è mostrato anche il riepilogo delle
|
||||
azioni registrate (punti, ace, muri, errori).
|
||||
</>,
|
||||
],
|
||||
].map(([titolo, testo]) => (
|
||||
<div key={titolo as string}>
|
||||
<dt className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo}
|
||||
</dt>
|
||||
<dd className="mt-0.5">{testo}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
{scout.valutazioni.length > 0 ? (
|
||||
<>
|
||||
{sets.length > 1 ? (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Periodo"
|
||||
className="mt-3 grid grid-flow-col auto-cols-fr gap-0.5 rounded-2xl bg-secondary p-1"
|
||||
>
|
||||
{[undefined, ...sets].map((s) => (
|
||||
<button
|
||||
key={s ?? "tutta"}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={s === set}
|
||||
onClick={() => setSet(s)}
|
||||
className={cn(
|
||||
"min-w-0 truncate rounded-xl px-0 py-2 text-[13px] font-bold uppercase transition-colors",
|
||||
s === set
|
||||
? "bg-primary text-primary-foreground shadow-card"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{s === undefined ? "Totale" : `Set ${s}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{fondamentali.map((f) => {
|
||||
const { piu, meno } = squadra[f.id];
|
||||
const p = pct(piu, meno);
|
||||
return (
|
||||
<div key={f.id} className="rounded-2xl bg-secondary p-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{f.label}
|
||||
</p>
|
||||
<p className="font-display text-xl leading-none">
|
||||
{p === null ? "—" : `${p}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex h-1.5 overflow-hidden rounded-full bg-border">
|
||||
<span className="bg-success" style={{ width: `${p ?? 0}%` }} />
|
||||
<span
|
||||
className="bg-destructive"
|
||||
style={{ width: p === null ? 0 : `${100 - p}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] font-semibold tabular-nums">
|
||||
<span className="text-success">+{piu}</span>
|
||||
<span className="mx-1.5 text-muted-foreground">·</span>
|
||||
<span className="text-destructive">−{meno}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Dettaglio giocatori
|
||||
</p>
|
||||
{righe.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Nessuna valutazione in questo set.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-hidden rounded-2xl bg-secondary/60">
|
||||
<table className="w-full table-fixed text-[13px]">
|
||||
<thead>
|
||||
<tr className="text-left text-[13px] uppercase text-muted-foreground">
|
||||
<th className="whitespace-nowrap px-2 py-2 font-bold">Giocatore</th>
|
||||
{fondamentali.map((f) => (
|
||||
<th key={f.id} className="w-11 px-0 py-2 text-center font-bold">
|
||||
{f.label.slice(0, 3)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{righe.map(({ g, conteggi }) => (
|
||||
<tr key={g.id}>
|
||||
<td className="px-2 py-2 font-semibold leading-tight [overflow-wrap:normal]">
|
||||
<span className="mr-1.5 tabular-nums text-muted-foreground">
|
||||
#{g.numero}
|
||||
</span>
|
||||
{g.nome}
|
||||
</td>
|
||||
{fondamentali.map((f) => (
|
||||
<td key={f.id} className="px-0 py-2 text-center font-semibold tabular-nums">
|
||||
<span className="text-success">{conteggi[f.id].piu}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-destructive">{conteggi[f.id].meno}</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Valutazioni: <span className="text-success">+</span> positive /{" "}
|
||||
<span className="text-destructive">−</span> negative. La percentuale è la quota di +.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{scout.azioni.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Azioni registrate
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ l: "Punti", v: totaliAzioni.punti },
|
||||
{ l: "Ace", v: totaliAzioni.ace },
|
||||
{ l: "Muri", v: totaliAzioni.muri },
|
||||
{ l: "Errori", v: totaliAzioni.errori },
|
||||
].map((t) => (
|
||||
<div key={t.l} className="rounded-2xl bg-secondary px-1 py-2.5">
|
||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
{t.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{[...azioni.entries()].map(([gid, t]) => {
|
||||
const g = rosa.find((x) => x.id === gid);
|
||||
if (!g) return null;
|
||||
return (
|
||||
<div
|
||||
key={gid}
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||
#{g.numero} {g.nome}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{scout.valutazioni.length === 0 && scout.azioni.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Nessun dato registrato per questa partita.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout, rosa))
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
</button>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -40,11 +40,15 @@ export function SondaggioCacche({
|
||||
const media = mediaPartita(righe, eventoId);
|
||||
const classifica = [...dellaPartita]
|
||||
.sort((a, b) => b.quantita - a.quantita)
|
||||
.slice(0, 3)
|
||||
.map((r) => {
|
||||
const g = squadra.find((g) => g.id === r.giocatore_id);
|
||||
return { ...r, nome: g ? nomeCompleto(g) : "—" };
|
||||
});
|
||||
})
|
||||
// Pari merito = stessa posizione, senza salti (1°, 1°, 2°).
|
||||
.map((r, _, tutti) => ({
|
||||
...r,
|
||||
pos: new Set(tutti.filter((t) => t.quantita > r.quantita).map((t) => t.quantita)).size + 1,
|
||||
}));
|
||||
|
||||
async function rispondi(quantita: number) {
|
||||
if (!io) return;
|
||||
@@ -89,6 +93,7 @@ export function SondaggioCacche({
|
||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||
</p>
|
||||
<Classifica righe={classifica} io={io?.id} media={media} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -126,19 +131,7 @@ export function SondaggioCacche({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
{classifica.map((r, i) => (
|
||||
<span
|
||||
key={r.giocatore_id}
|
||||
className="rounded-full bg-secondary px-2.5 py-1 font-semibold"
|
||||
>
|
||||
{i === 0 ? "🥇" : i === 1 ? "🥈" : "🥉"} {r.nome} · {r.quantita}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Classifica righe={classifica} io={io?.id} media={media} />
|
||||
|
||||
{admin ? (
|
||||
<button
|
||||
@@ -154,3 +147,61 @@ export function SondaggioCacche({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const MEDAGLIE = ["🥇", "🥈", "🥉"];
|
||||
|
||||
type RigaClassifica = { giocatore_id: string; nome: string; quantita: number; pos: number };
|
||||
|
||||
function Classifica({
|
||||
righe,
|
||||
io,
|
||||
media,
|
||||
}: {
|
||||
righe: RigaClassifica[];
|
||||
io: string | undefined;
|
||||
media: number | string;
|
||||
}) {
|
||||
if (righe.length === 0) return null;
|
||||
const max = Math.max(...righe.map((r) => r.quantita), 1);
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between text-[13px]">
|
||||
<span className="font-bold uppercase tracking-wide text-muted-foreground">Classifica</span>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="divide-y divide-border overflow-hidden rounded-2xl bg-secondary/60">
|
||||
{righe.map((r) => (
|
||||
<li
|
||||
key={r.giocatore_id}
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 px-3 py-2.5",
|
||||
r.giocatore_id === io && "bg-accent/15",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-y-0 left-0 bg-accent/10"
|
||||
style={{ width: `${(r.quantita / max) * 100}%` }}
|
||||
/>
|
||||
<span className="relative w-7 text-center text-[15px] font-bold tabular-nums text-muted-foreground">
|
||||
{r.pos <= 3 ? MEDAGLIE[r.pos - 1] : `${r.pos}°`}
|
||||
</span>
|
||||
<span className="relative min-w-0 flex-1 truncate text-[15px] font-semibold">
|
||||
{r.nome}
|
||||
{r.giocatore_id === io ? (
|
||||
<span className="ml-1.5 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
tu
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="relative text-[17px] font-bold tabular-nums">
|
||||
{r.quantita === 5 ? "5+" : r.quantita}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -310,11 +310,14 @@ export function SezioneTendina({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperta((v) => !v)}
|
||||
className="mb-3 flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]",
|
||||
(aperta || anteprima) && "mb-3",
|
||||
)}
|
||||
aria-expanded={aperta}
|
||||
aria-controls={id}
|
||||
>
|
||||
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
|
||||
<h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{azione}
|
||||
<ChevronDown
|
||||
@@ -336,9 +339,11 @@ export function SezioneTendina({
|
||||
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
|
||||
* con `appearance-none`, restano un filo più alti di un input di testo a
|
||||
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
|
||||
* `text-base` (16px) non è estetica: sotto i 16px iOS Safari zooma la pagina al focus e,
|
||||
* dopo il blur, la lascia ingrandita e tagliata in larghezza.
|
||||
*/
|
||||
export const classiInput =
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-base";
|
||||
|
||||
/**
|
||||
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
|
||||
|
||||
@@ -96,6 +96,24 @@ export function contaValutazioni(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riepilogo delle valutazioni per la lettura nell'app: totali di squadra e per giocatore,
|
||||
* su tutta la partita o su un solo set. Chi non ha valutazioni nel periodo non compare.
|
||||
*/
|
||||
export function sintesiValutazioni(valutazioni: Valutazione[], set?: number) {
|
||||
const filtrate = set === undefined ? valutazioni : valutazioni.filter((v) => v.set === set);
|
||||
const squadra = contaValutazioni([], "", undefined);
|
||||
const perGiocatore = [...new Set(filtrate.map((v) => v.giocatoreId))].map((giocatoreId) => {
|
||||
const c = contaValutazioni(filtrate, giocatoreId);
|
||||
for (const f of fondamentali) {
|
||||
squadra[f.id].piu += c[f.id].piu;
|
||||
squadra[f.id].meno += c[f.id].meno;
|
||||
}
|
||||
return { giocatoreId, conteggi: c };
|
||||
});
|
||||
return { squadra, perGiocatore };
|
||||
}
|
||||
|
||||
/**
|
||||
* I + di un giocatore in un set, esclusa la ricezione (che qui non vale come punto messo a
|
||||
* terra): quello che compare nel suo riquadro dello scout.
|
||||
@@ -273,6 +291,46 @@ export function totaliSquadra(matches: ScoutMatch[]) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export type TotaliTecnici = { punti: number; ace: number; attacchi: number; muri: number };
|
||||
|
||||
/**
|
||||
* Somma, su tutte le partite scoutate, delle sole valutazioni **+** (battuta → ace, attacco →
|
||||
* attacchi, muro → muri; punti = ace + attacchi + muri, non la ricezione). Con `giocatoreId`
|
||||
* conta solo quel giocatore, altrimenti tutta la squadra. Una partita senza valutazioni (metodo
|
||||
* precedente) conta con le sue azioni; se ha entrambe valgono solo le valutazioni, per non
|
||||
* contare due volte.
|
||||
*/
|
||||
export function totaliTecnici(matches: ScoutMatch[], giocatoreId?: string): TotaliTecnici {
|
||||
const out = { punti: 0, ace: 0, attacchi: 0, muri: 0 };
|
||||
for (const m of matches) {
|
||||
if (m.valutazioni.length === 0) {
|
||||
for (const [id, t] of totaliPerGiocatore(m.azioni)) {
|
||||
if (giocatoreId !== undefined && id !== giocatoreId) continue;
|
||||
out.ace += t.ace;
|
||||
out.muri += t.muri;
|
||||
out.attacchi += t.punti - t.ace - t.muri;
|
||||
out.punti += t.punti;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const v of m.valutazioni) {
|
||||
if (v.segno !== "+" || v.fondamentale === "ricezione") continue;
|
||||
if (giocatoreId !== undefined && v.giocatoreId !== giocatoreId) continue;
|
||||
if (v.fondamentale === "battuta") out.ace += 1;
|
||||
if (v.fondamentale === "attacco") out.attacchi += 1;
|
||||
if (v.fondamentale === "muro") out.muri += 1;
|
||||
out.punti += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Punti, ace e muri di squadra per la tab Stats di `/squadra` (vedi `totaliTecnici`). */
|
||||
export function totaliSquadraValutazioni(matches: ScoutMatch[]) {
|
||||
const { punti, ace, muri } = totaliTecnici(matches);
|
||||
return { punti, ace, muri };
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP ricavati dalle partite scoutate (mappa matchId → nome vincitore).
|
||||
* Le presenze personali restano su `risposte_presenze`, non sullo scout.
|
||||
|
||||
+44
-71
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { Card, PageHeader, Section, SezioneTendina } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
@@ -10,9 +10,10 @@ import { matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
import { sondaggioAperto } from "@/lib/cacche";
|
||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { ScoutReport } from "@/components/crapp/ScoutReport";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
@@ -90,7 +91,6 @@ function PartitaDetail() {
|
||||
: csiMatch
|
||||
? matchDaPartitaCsi(csiMatch)
|
||||
: undefined;
|
||||
const totaliTeam = scout ? totaliSquadra([scout]) : null;
|
||||
const avversario = evento.titolo.includes(" vs ")
|
||||
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
|
||||
: evento.titolo;
|
||||
@@ -206,9 +206,16 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
{csiMatch ? (
|
||||
<Section titolo="Formazioni e scontri diretti">
|
||||
<SezioneTendina
|
||||
titolo="Formazioni e scontri diretti"
|
||||
azione={
|
||||
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
||||
Livescore CSI
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
<Section titolo="MVP eletto dalla squadra">
|
||||
@@ -220,9 +227,9 @@ function PartitaDetail() {
|
||||
{match ? (
|
||||
// Badge e cacche non si mostrano all'allenatore (DD-034).
|
||||
sonoAllenatore ? null : (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<SezioneTendina titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
)
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
@@ -238,83 +245,49 @@ function PartitaDetail() {
|
||||
</Section>
|
||||
|
||||
{sonoAllenatore ? null : (
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SezioneTendina
|
||||
titolo="Sondaggio pre-partita"
|
||||
defaultAperta={sondaggioAperto(evento.data, evento.ora)}
|
||||
>
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
)}
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Pagelle di fine partita">
|
||||
<Pagelle matchId={match.id} convocati={convocati} chiuse={evento.pagelleChiuse} />
|
||||
</Section>
|
||||
<SezioneTendina titolo="Pagelle di fine partita">
|
||||
<Pagelle
|
||||
matchId={match.id}
|
||||
convocati={convocati.filter(
|
||||
(g) => risposte[g.id] !== "assente" && risposte[g.id] !== "infortunato",
|
||||
)}
|
||||
chiuse={evento.pagelleChiuse}
|
||||
/>
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
{scout && totaliTeam ? (
|
||||
<Section titolo="Report tecnico">
|
||||
<Card>
|
||||
<div className="grid grid-cols-4 gap-2 text-center">
|
||||
{[
|
||||
{ l: "Punti", v: totaliTeam.punti },
|
||||
{ l: "Ace", v: totaliTeam.ace },
|
||||
{ l: "Muri", v: totaliTeam.muri },
|
||||
{ l: "Errori", v: totaliTeam.errori },
|
||||
].map((t) => (
|
||||
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
{t.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Dettaglio giocatori (uso interno allenatori)
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{[...totaliPerGiocatore(scout.azioni).entries()].map(([gid, t]) => {
|
||||
const g = rosa.find((x) => x.id === gid);
|
||||
if (!g) return null;
|
||||
return (
|
||||
<div
|
||||
key={gid}
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||
#{g.numero} {g.nome}
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t.punti}P · {t.ace}A · {t.muri}M · {t.errori}E
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(
|
||||
`scout-${scout.data}-${scout.avversario}.csv`,
|
||||
csvScoutMatch(scout, rosa),
|
||||
)
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
</button>
|
||||
) : null}
|
||||
</Card>
|
||||
</Section>
|
||||
{scout ? (
|
||||
<SezioneTendina
|
||||
titolo="Scout"
|
||||
azione={
|
||||
scout.valutazioni.length > 0 ? (
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
{scout.valutazioni.length} valutazioni
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<ScoutReport scout={scout} rosa={rosa} admin={admin} />
|
||||
</SezioneTendina>
|
||||
) : null}
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<SezioneTendina titolo="Rosa e presenze" defaultAperta>
|
||||
<RosaPresenze
|
||||
eventoId={evento.id}
|
||||
data={evento.data}
|
||||
tipo={evento.tipo}
|
||||
convocati={evento.convocati}
|
||||
/>
|
||||
</Section>
|
||||
</SezioneTendina>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { esci } from "@/lib/auth";
|
||||
import { totaliTecnici, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useEmailNotifiche } from "@/lib/preferenze-utente";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -80,9 +81,12 @@ function ProfiloGiocatore() {
|
||||
const votiSocial = useVotiSocial();
|
||||
const g = useIo();
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const scoutMatches = useScoutMatches();
|
||||
|
||||
if (!g) return null;
|
||||
|
||||
const tecnici = totaliTecnici(scoutMatches, g.id);
|
||||
|
||||
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
|
||||
|
||||
return (
|
||||
@@ -122,9 +126,12 @@ function ProfiloGiocatore() {
|
||||
}
|
||||
label="Presenze di fila"
|
||||
/>
|
||||
<StatTile valore={g.mediaVoto || "—"} label="Media voto" />
|
||||
<StatTile valore={tecnici.attacchi} label="Attacchi" />
|
||||
<StatTile valore={tecnici.muri} label="Muri" />
|
||||
<StatTile valore={tecnici.ace} label="Ace" />
|
||||
<StatTile valore={tecnici.punti} label="Punti" hint="totale" />
|
||||
<StatTile valore={g.mvp} label="MVP" />
|
||||
<StatTile valore={g.palloni} label="Turni palloni" />
|
||||
<StatTile valore={g.mediaVoto || "—"} label="Media voto" />
|
||||
</div>
|
||||
<SerieGriglia g={g} />
|
||||
</div>
|
||||
|
||||
@@ -384,7 +384,7 @@ function ScoutBoard({
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-3 gap-2 min-[400px]:grid-cols-4">
|
||||
{convocati.map((g) => {
|
||||
const attivo = selezionato === g.id;
|
||||
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
|
||||
@@ -394,12 +394,14 @@ function ScoutBoard({
|
||||
type="button"
|
||||
onClick={() => setSelezionato(attivo ? null : g.id)}
|
||||
className={cn(
|
||||
"rounded-2xl p-2 text-center transition-all active:scale-95",
|
||||
"rounded-2xl p-1.5 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-[13px] font-bold leading-tight">{g.nome}</p>
|
||||
<p className="mt-1 text-[13px] font-bold leading-tight [overflow-wrap:normal]">
|
||||
{g.nome}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] tabular-nums",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useGiocatoriSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
||||
import { totaliSquadraValutazioni, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { partiteGiocate } from "@/lib/csi-core";
|
||||
import { mediaSquadra, usePagelle } from "@/lib/pagelle";
|
||||
@@ -110,7 +110,7 @@ function Squadra() {
|
||||
const [criterio, setCriterio] = useState<Criterio>("presenze");
|
||||
const [filtroAperto, setFiltroAperto] = useState(false);
|
||||
const obiettivi = useObiettivi();
|
||||
const team = totaliSquadra(scoutMatches);
|
||||
const team = totaliSquadraValutazioni(scoutMatches);
|
||||
const mediaPresenze = rosa.length
|
||||
? Math.round(
|
||||
(rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100,
|
||||
|
||||
@@ -520,11 +520,31 @@
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mai scroll orizzontale della pagina (iPhone 360-402 px). `clip` e non `hidden`:
|
||||
* non crea un contenitore di scroll, quindi `position: sticky` continua a funzionare.
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
max-width: 100%;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/*
|
||||
* Gli elementi di una griglia hanno `min-width: auto`: una colonna `1fr` non scende sotto
|
||||
* la parola più lunga e sfonda la riga. Con `min-width: 0` la colonna resta nel suo posto
|
||||
* e `overflow-wrap` spezza solo la parola che davvero non ci sta.
|
||||
*/
|
||||
.grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
font-optical-sizing: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
/* Niente pull-to-refresh accidentale né rimbalzo che scopre il fondo. */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Check dei requisiti di layout per schermi stretti (360 px): `bun test/unit/layout-stretto.test.ts`.
|
||||
*
|
||||
* Non c'è un driver browser (vedi `test/README.md`), quindi si presidiano le due regole che,
|
||||
* se tolte, fanno tornare la pagina tagliata in larghezza su iPhone: i campi a 16 px (sotto,
|
||||
* iOS zooma al focus e non torna indietro) e la rete di sicurezza sull'overflow orizzontale.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { classiInput } from "@/components/crapp/ui-bits";
|
||||
|
||||
assert.match(classiInput, /\btext-base\b/, "i campi devono essere a 16px (zoom automatico iOS)");
|
||||
assert.doesNotMatch(classiInput, /text-\[1[0-5]px\]|text-sm|text-xs/);
|
||||
|
||||
const css = readFileSync(new URL("../../src/styles.css", import.meta.url), "utf8");
|
||||
assert.match(css, /overflow-x:\s*clip/, "html/body devono impedire lo scroll orizzontale");
|
||||
assert.match(
|
||||
css,
|
||||
/\.grid\s*>\s*\*\s*\{\s*min-width:\s*0/,
|
||||
"gli elementi di griglia devono poter restringersi",
|
||||
);
|
||||
|
||||
const scout = readFileSync(
|
||||
new URL("../../src/components/crapp/ScoutReport.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
scout,
|
||||
/min-w-\[\d+rem\]/,
|
||||
"la tabella scout non deve imporre una larghezza minima",
|
||||
);
|
||||
|
||||
console.log("layout-stretto: ok");
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
contaPiuSenzaRicezione,
|
||||
haPunteggio,
|
||||
parzialiFinali,
|
||||
sintesiValutazioni,
|
||||
contaValutazioni,
|
||||
fondamentali,
|
||||
giocatoriConScout,
|
||||
totaliPerGiocatore,
|
||||
totaliSquadra,
|
||||
totaliSquadraValutazioni,
|
||||
totaliTecnici,
|
||||
type Azione,
|
||||
type Valutazione,
|
||||
type ScoutMatch,
|
||||
@@ -66,6 +69,60 @@ assert.deepEqual(totaliSquadra([match("m1", 3, 0, azioni), match("m2", 3, 1, azi
|
||||
});
|
||||
assert.deepEqual(totaliSquadra([]), { punti: 0, ace: 0, muri: 0, errori: 0 });
|
||||
|
||||
// --- totaliSquadraValutazioni: solo i + dello scout, fallback sulle azioni ------
|
||||
const vl = (fondamentale: Valutazione["fondamentale"], segno: "+" | "-", giocatoreId = "g1") =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
giocatoreId,
|
||||
fondamentale,
|
||||
segno,
|
||||
set: 1,
|
||||
ts: 0,
|
||||
}) as Valutazione;
|
||||
const conVal = {
|
||||
...match("v1", 3, 0),
|
||||
valutazioni: [
|
||||
vl("battuta", "+"),
|
||||
vl("battuta", "+", "g2"),
|
||||
vl("battuta", "-"),
|
||||
vl("muro", "+"),
|
||||
vl("muro", "-"),
|
||||
vl("attacco", "+"),
|
||||
vl("attacco", "+", "g2"),
|
||||
vl("attacco", "-"),
|
||||
vl("ricezione", "+"),
|
||||
vl("ricezione", "+"),
|
||||
],
|
||||
};
|
||||
assert.deepEqual(totaliSquadraValutazioni([conVal]), { punti: 5, ace: 2, muri: 1 });
|
||||
assert.deepEqual(
|
||||
totaliSquadraValutazioni([conVal, conVal]),
|
||||
{ punti: 10, ace: 4, muri: 2 },
|
||||
"somma su più partite",
|
||||
);
|
||||
assert.deepEqual(
|
||||
totaliSquadraValutazioni([match("a1", 3, 0, azioni)]),
|
||||
{ punti: 4, ace: 1, muri: 1 },
|
||||
"senza valutazioni: contano le azioni del metodo precedente",
|
||||
);
|
||||
assert.deepEqual(
|
||||
totaliSquadraValutazioni([{ ...conVal, azioni }]),
|
||||
{ punti: 5, ace: 2, muri: 1 },
|
||||
"con entrambe valgono solo le valutazioni",
|
||||
);
|
||||
assert.deepEqual(totaliSquadraValutazioni([]), { punti: 0, ace: 0, muri: 0 });
|
||||
|
||||
// --- totaliTecnici: per giocatore, con attacchi -------------------------------
|
||||
assert.deepEqual(totaliTecnici([conVal], "g1"), { punti: 3, ace: 1, attacchi: 1, muri: 1 });
|
||||
assert.deepEqual(totaliTecnici([conVal], "g2"), { punti: 2, ace: 1, attacchi: 1, muri: 0 });
|
||||
assert.deepEqual(totaliTecnici([conVal], "assente"), { punti: 0, ace: 0, attacchi: 0, muri: 0 });
|
||||
assert.deepEqual(totaliTecnici([conVal, conVal]), { punti: 10, ace: 4, attacchi: 4, muri: 2 });
|
||||
assert.deepEqual(
|
||||
totaliTecnici([match("a1", 3, 0, azioni)], "g1"),
|
||||
{ punti: 3, ace: 1, attacchi: 1, muri: 1 },
|
||||
"metodo precedente: ace, muro e attacco dalle azioni",
|
||||
);
|
||||
|
||||
// --- giocatoriConScout: solo MVP da scout, presenze separate -----------------
|
||||
const base = giocatori.find((g) => g.id === "g1")!;
|
||||
const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome });
|
||||
@@ -197,3 +254,19 @@ assert.equal(
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// --- sintesi per la lettura nell'app: squadra e giocatori, partita o singolo set -----
|
||||
const tutta = sintesiValutazioni(val);
|
||||
assert.deepEqual(tutta.squadra.battuta, { piu: 2, meno: 1 });
|
||||
assert.deepEqual(tutta.squadra.attacco, { piu: 1, meno: 0 });
|
||||
assert.deepEqual(tutta.squadra.ricezione, { piu: 0, meno: 1 });
|
||||
assert.deepEqual(tutta.perGiocatore.map((r) => r.giocatoreId).sort(), ["g1", "g2"]);
|
||||
const set2 = sintesiValutazioni(val, 2);
|
||||
assert.deepEqual(
|
||||
set2.perGiocatore.map((r) => r.giocatoreId),
|
||||
["g1"],
|
||||
);
|
||||
assert.deepEqual(set2.squadra.muro, { piu: 1, meno: 0 });
|
||||
assert.deepEqual(set2.squadra.battuta, { piu: 0, meno: 0 });
|
||||
assert.equal(sintesiValutazioni([]).perGiocatore.length, 0);
|
||||
console.log("scout-store sintesi: ok");
|
||||
Reference in new issue
Block a user