Author SHA1 Message Date
davideandClaude Sonnet 5.5 7abf27bf76 Presentazione commerciale di CrAPP con guida di supporto
Presentazione-CrAPP.pptx (16 slide con schermate dei tre ruoli, da dati di fantasia)
e GUIDA.md con traccia per slide, demo e domande frequenti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-15 00:26:38 +01:00
davideandClaude Sonnet 5.5 5281c0c62b Scout: nomi mai spezzati a metà parola, riquadri a 3 colonne sotto i 400 px
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:45:43 +01:00
davideandClaude Sonnet 5.5 3a892a78e6 Versione 1.6.2
Layout corretto sugli schermi stretti (360-402 px) e Ace, Attacchi, Muri e Punti dai +
dello scout in Squadra e Profilo. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:42:39 +01:00
davideandClaude Sonnet 5.5 1e4ec9113e Squadra e Profilo: ace, attacchi, muri e punti dai + dello scout
Squadra → Stats: punti, ace e muri di squadra sommano i + delle valutazioni scout di tutte
le partite. Profilo → Season: al posto di «Turni palloni» compaiono Attacchi, Muri, Ace e
Punti (totale) del giocatore. Le partite del metodo precedente contano con le azioni.
DD-008 aggiornata: dati solo informativi, fuori da badge e classifiche.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 10:33:49 +01:00
davideandClaude Sonnet 5.5 263621456d Layout su schermi stretti: scout e griglie non tagliati a 360 px
Tabella scout senza larghezza minima (nome a capo, colonne compatte), griglie con
min-width 0, niente scroll orizzontale della pagina, campi a 16px contro lo zoom iOS.
Fixes #12

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 09:54:10 +01:00
18 changed files with 296 additions and 23 deletions

No files matched your search

+4 -2
View File
@@ -9,8 +9,10 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## Stato generale
- **Versione:** 1.6.1 su `main`: risultati dello scout leggibili nella pagina - **Versione:** 1.6.2 su `main`: layout corretto sugli schermi stretti (360-402 px) e
partita, classifica cacche completa e pagina partita a sezioni a scomparsa, senza migration. La 1.6.0 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 (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`, 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 nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
+18
View File
@@ -13,6 +13,24 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato] ## [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 ## [1.6.1] - 2026-11-06
Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni Risultati dello scout leggibili nell'app, classifica cacche completa e pagina partita a sezioni
+3
View File
@@ -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. - Badge e obiettivi usano presenze, MVP, pagelle, serie, cacche — metriche accessibili a tutti.
- Lo scout resta strumento tecnico, non gioco. - 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** **Riesame**
Se la squadra chiede esplicitamente classifiche tecniche per ruolo. Se la squadra chiede esplicitamente classifiche tecniche per ruolo.
+8
View File
@@ -285,8 +285,16 @@ Sezione già presente. Contiene.
- Voto medio - Voto medio
- MVP - MVP
- Serie - 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 - Altre statistiche disponibili
Sono numeri informativi: non alimentano badge, obiettivi né classifiche (DD-008).
### Badge ### Badge
Sezione già presente. Sezione già presente.
+7
View File
@@ -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 sottostatistica mostrare sotto il nome, coerente col criterio selezionato (es. "voti
pagella" per il criterio media voto, non sempre "presenze consecutive"). 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 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 `useRosa()`/`useObiettivi()`/`badges.ts`: non introducono altra logica di dominio, solo
presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`. presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squadra.md`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.6.1", "version": "1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.6.1", "version": "1.6.2",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.111.0", "@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.6.1", "version": "1.6.2",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
+75
View File
@@ -0,0 +1,75 @@
# Guida alla presentazione di CrAPP
Documento di supporto a [Presentazione-CrAPP.pptx](Presentazione-CrAPP.pptx): cosa dire slide per
slide, come condurre la demo e come rispondere alle domande più frequenti. Le funzionalità citate
vengono da [docs/ROADMAP.md](../docs/ROADMAP.md) e dai documenti in [docs/modules/](../docs/modules/);
se l'app cambia, si aggiorna prima la documentazione e poi questa guida.
## In breve
| Cosa | Dettaglio |
| ---------------- | ---------------------------------------------------------------------------------------- |
| A chi si rivolge | Società e allenatori di pallavolo, a cui si propone CrAPP |
| Durata | 25-30 minuti con 16 slide (circa 1,5 minuti l'una), più 10 di domande |
| Messaggio | Un'unica app sul telefono di tutti sostituisce chat, fogli Excel e carta |
| Obiettivo | Fissare una demo sulla squadra del cliente |
| Schermate | Reali, ottenute da un database di prova con giocatori di fantasia: nessun dato personale |
## Prima di presentare
- Compila i contatti nell'ultima slide: oggi c'è il segnaposto «[inserisci nome, email, telefono]».
- Decidi se tenere o togliere «avvio in pochi giorni» nella chiusura: è una stima, non un impegno scritto.
- Le schermate sono un'istantanea. Il calendario mostra le date in cui sono state fatte (metà novembre 2026).
- Per la demo dal vivo usa un account di prova e dati di fantasia, mai la squadra reale davanti a terzi.
- Controlla la connessione: l'app è online e senza rete la demo non parte. Tieni le slide come piano B.
## Slide per slide
| N. | Slide | Cosa dire | Tempo |
| --- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| 1 | Copertina | CrAPP gestisce l'intera vita di una squadra di pallavolo, dal telefono di ogni giocatore. | 0:30 |
| 2 | Dalla chat caotica | Parti dai loro problemi: presenze rincorse su WhatsApp, fogli sparsi, scout su carta, certificati scaduti, palloni dimenticati. Chiedi quali riconoscono. | 2:00 |
| 3 | Tutto quello che serve | Le sei aree: calendario, presenze, scout live, campionato, gamification, notifiche. Le vedremo una per una. | 1:00 |
| 4 | Tre ruoli | Giocatore, allenatore e admin vedono cose diverse. I permessi li applica il database, non solo l'interfaccia. | 1:30 |
| 5 | Il giocatore | Risponde con un tap, ha il suo profilo con badge e statistiche, vota pagelle e MVP. | 2:00 |
| 6 | L'allenatore | Crea eventi e convoca, sollecita con un tap chi non ha risposto, non ha badge né voti perché non gioca. | 2:00 |
| 7 | L'admin | Dashboard, certificati medici mancanti e scaduti con i nomi, profili e documenti, export CSV per il tesseramento. | 2:00 |
| 8 | Calendario e presenze | Un solo calendario per partite, allenamenti ed eventi extra. Cinque risposte possibili. La serie di presenze premia la costanza. | 2:00 |
| 9 | Scout Live | Un referente alla volta valuta con − e + battuta, ricezione, attacco e muro. Salvato online e archiviato. Statistiche di squadra, non classifiche individuali. | 2:30 |
| 10 | Campionato | Collegamento con il CSI: classifica, calendario ufficiale, dettaglio gara con formazioni, scontri diretti e probabilità di vittoria. | 1:30 |
| 11 | Gruppo | Badge (anche segreti e votati dai compagni), pagelle anonime, MVP, obiettivi di squadra: coinvolgono senza mettere in competizione. | 2:00 |
| 12 | Dettagli | Turno palloni a rotazione, infortuni, compleanni, ruolo allenatore. | 1:00 |
| 13 | Notifiche | Push, centro notifiche in app ed email, con interruttore personale. Promemoria automatici su palloni, presenze, certificati e compleanni. | 1:30 |
| 14 | Burocrazia | Dashboard, profilo giocatore, avviso certificati, tesseramenti CSI, export CSV, ruoli. | 1:30 |
| 15 | Tecnologia | Si installa dalla schermata Home senza store, login con Google, dati su cloud, portabili su un server proprio. | 1:30 |
| 16 | Chiusura | Proponi la demo sulla loro squadra e fissa una data. | 1:00 |
## Demo dal vivo
Se hai tempo, dopo la slide 9 passa dall'app. Percorso consigliato, circa 5 minuti:
1. Da **giocatore**: Home, risposta a un allenamento, profilo con i badge.
2. Da **allenatore**: crea un evento, convoca la rosa, premi «Sollecita».
3. Da **admin**: dashboard con i certificati scaduti, poi Scout Live con qualche − e +.
4. Mostra la notifica che arriva sul telefono: è l'effetto che convince di più.
## Domande frequenti
| Domanda | Risposta, dai documenti del progetto |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Bisogna scaricarla dallo store? | No: è una web app installabile (PWA) dalla schermata Home, su iPhone e Android. |
| Come si accede? | Con l'account Google. Al primo accesso il giocatore viene collegato al suo slot tramite l'email. |
| Chi vede i documenti e i certificati? | Solo il titolare e gli admin. I file stanno in uno spazio privato e si scaricano con link a scadenza breve. |
| I dati restano nostri? | Sì: export CSV e backup. Nel progetto è documentato come spostare l'app su un server proprio ([PORTABILITA.md](../docs/PORTABILITA.md)). |
| Serve per forza il campionato CSI? | No. Il collegamento CSI è un modulo a parte: calendario, presenze e scout funzionano anche senza. |
| Gli scout creano classifiche personali? | No, di proposito: le statistiche sono di squadra e per fondamentale, perché nessuno si senta giudicato. |
| Le notifiche arrivano anche via email? | Sì, su tre canali (push, in app, email), con interruttore personale in Profilo. Il turno palloni è solo push e in app. |
| Funziona per altri sport o altri enti? | Oggi è pensata per la pallavolo e per il CSI. Altro va valutato caso per caso: non promettere nulla. |
| Quanto costa? Quando parte? | Non è nei documenti del progetto: prezzi, tempi e condizioni vanno decisi da chi presenta prima dell'incontro. |
## Cosa non promettere
- AI Allenamenti, database esercizi e archivio allenamenti sono in roadmap, non disponibili.
- Gestione quote, calendario Google, backup automatici, analisi avanzate e widget meteo sono solo idee future.
- Il tracciamento di numero e data di tessera CSI è stato tolto dalla dashboard: resta la raccolta dati con export CSV.
- La foto tessera è stata tolta: restano documento d'identità e certificato medico.
Binary file not shown.
+10 -10
View File
@@ -124,7 +124,7 @@ export function ScoutReport({
<div <div
role="tablist" role="tablist"
aria-label="Periodo" aria-label="Periodo"
className="mt-3 grid grid-flow-col auto-cols-fr gap-1 rounded-2xl bg-secondary p-1" className="mt-3 grid grid-flow-col auto-cols-fr gap-0.5 rounded-2xl bg-secondary p-1"
> >
{[undefined, ...sets].map((s) => ( {[undefined, ...sets].map((s) => (
<button <button
@@ -134,7 +134,7 @@ export function ScoutReport({
aria-selected={s === set} aria-selected={s === set}
onClick={() => setSet(s)} onClick={() => setSet(s)}
className={cn( className={cn(
"min-w-0 truncate rounded-xl px-1 py-2 text-[13px] font-bold uppercase transition-colors", "min-w-0 truncate rounded-xl px-0 py-2 text-[13px] font-bold uppercase transition-colors",
s === set s === set
? "bg-primary text-primary-foreground shadow-card" ? "bg-primary text-primary-foreground shadow-card"
: "text-muted-foreground", : "text-muted-foreground",
@@ -185,13 +185,13 @@ export function ScoutReport({
Nessuna valutazione in questo set. Nessuna valutazione in questo set.
</p> </p>
) : ( ) : (
<div className="mt-2 overflow-x-auto rounded-2xl bg-secondary/60"> <div className="mt-2 overflow-hidden rounded-2xl bg-secondary/60">
<table className="w-full min-w-[22rem] text-[13px]"> <table className="w-full table-fixed text-[13px]">
<thead> <thead>
<tr className="text-left text-[13px] uppercase text-muted-foreground"> <tr className="text-left text-[13px] uppercase text-muted-foreground">
<th className="px-3 py-2 font-bold">Giocatore</th> <th className="whitespace-nowrap px-2 py-2 font-bold">Giocatore</th>
{fondamentali.map((f) => ( {fondamentali.map((f) => (
<th key={f.id} className="px-2 py-2 text-center font-bold"> <th key={f.id} className="w-11 px-0 py-2 text-center font-bold">
{f.label.slice(0, 3)} {f.label.slice(0, 3)}
</th> </th>
))} ))}
@@ -200,16 +200,16 @@ export function ScoutReport({
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{righe.map(({ g, conteggi }) => ( {righe.map(({ g, conteggi }) => (
<tr key={g.id}> <tr key={g.id}>
<td className="max-w-40 truncate px-3 py-2 font-semibold"> <td className="px-2 py-2 font-semibold leading-tight [overflow-wrap:normal]">
<span className="mr-1.5 tabular-nums text-muted-foreground"> <span className="mr-1.5 tabular-nums text-muted-foreground">
#{g.numero} #{g.numero}
</span> </span>
{g.nome} {g.nome}
</td> </td>
{fondamentali.map((f) => ( {fondamentali.map((f) => (
<td key={f.id} className="px-2 py-2 text-center font-semibold tabular-nums"> <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-success">{conteggi[f.id].piu}</span>
<span className="text-muted-foreground"> / </span> <span className="text-muted-foreground">/</span>
<span className="text-destructive">{conteggi[f.id].meno}</span> <span className="text-destructive">{conteggi[f.id].meno}</span>
</td> </td>
))} ))}
@@ -238,7 +238,7 @@ export function ScoutReport({
{ l: "Muri", v: totaliAzioni.muri }, { l: "Muri", v: totaliAzioni.muri },
{ l: "Errori", v: totaliAzioni.errori }, { l: "Errori", v: totaliAzioni.errori },
].map((t) => ( ].map((t) => (
<div key={t.l} className="rounded-2xl bg-secondary p-2.5"> <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="font-display text-xl leading-none">{t.v}</p>
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground"> <p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
{t.l} {t.l}
+3 -1
View File
@@ -339,9 +339,11 @@ export function SezioneTendina({
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche * `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 * 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. * 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 = 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 * Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
+40
View File
@@ -291,6 +291,46 @@ export function totaliSquadra(matches: ScoutMatch[]) {
return out; 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). * MVP ricavati dalle partite scoutate (mappa matchId → nome vincitore).
* Le presenze personali restano su `risposte_presenze`, non sullo scout. * Le presenze personali restano su `risposte_presenze`, non sullo scout.
+9 -2
View File
@@ -33,6 +33,7 @@ import {
type GiocatoreSquadra, type GiocatoreSquadra,
} from "@/lib/giocatori-squadra"; } from "@/lib/giocatori-squadra";
import { esci } from "@/lib/auth"; import { esci } from "@/lib/auth";
import { totaliTecnici, useScoutMatches } from "@/lib/scout-store";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useEmailNotifiche } from "@/lib/preferenze-utente"; import { useEmailNotifiche } from "@/lib/preferenze-utente";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
@@ -80,9 +81,12 @@ function ProfiloGiocatore() {
const votiSocial = useVotiSocial(); const votiSocial = useVotiSocial();
const g = useIo(); const g = useIo();
const ultimoMese = usePresenzeUltimoMese(g?.id); const ultimoMese = usePresenzeUltimoMese(g?.id);
const scoutMatches = useScoutMatches();
if (!g) return null; if (!g) return null;
const tecnici = totaliTecnici(scoutMatches, g.id);
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0; const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
return ( return (
@@ -122,9 +126,12 @@ function ProfiloGiocatore() {
} }
label="Presenze di fila" 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.mvp} label="MVP" />
<StatTile valore={g.palloni} label="Turni palloni" /> <StatTile valore={g.mediaVoto || "—"} label="Media voto" />
</div> </div>
<SerieGriglia g={g} /> <SerieGriglia g={g} />
</div> </div>
+5 -3
View File
@@ -384,7 +384,7 @@ function ScoutBoard({
</Select> </Select>
</div> </div>
</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) => { {convocati.map((g) => {
const attivo = selezionato === g.id; const attivo = selezionato === g.id;
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente); const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
@@ -394,12 +394,14 @@ function ScoutBoard({
type="button" type="button"
onClick={() => setSelezionato(attivo ? null : g.id)} onClick={() => setSelezionato(attivo ? null : g.id)}
className={cn( 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", 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="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 <p
className={cn( className={cn(
"text-[13px] tabular-nums", "text-[13px] tabular-nums",
+2 -2
View File
@@ -23,7 +23,7 @@ import {
useGiocatoriSquadra, useGiocatoriSquadra,
} from "@/lib/giocatori-squadra"; } from "@/lib/giocatori-squadra";
import { useSonoAllenatore } from "@/lib/user-store"; 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 { useCsi } from "@/lib/csi";
import { partiteGiocate } from "@/lib/csi-core"; import { partiteGiocate } from "@/lib/csi-core";
import { mediaSquadra, usePagelle } from "@/lib/pagelle"; import { mediaSquadra, usePagelle } from "@/lib/pagelle";
@@ -110,7 +110,7 @@ function Squadra() {
const [criterio, setCriterio] = useState<Criterio>("presenze"); const [criterio, setCriterio] = useState<Criterio>("presenze");
const [filtroAperto, setFiltroAperto] = useState(false); const [filtroAperto, setFiltroAperto] = useState(false);
const obiettivi = useObiettivi(); const obiettivi = useObiettivi();
const team = totaliSquadra(scoutMatches); const team = totaliSquadraValutazioni(scoutMatches);
const mediaPresenze = rosa.length const mediaPresenze = rosa.length
? Math.round( ? Math.round(
(rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100, (rosa.reduce((s, g) => s + g.presenze / (g.totaliEventi || 1), 0) / rosa.length) * 100,
+20
View File
@@ -520,11 +520,31 @@
border-color: var(--color-border); 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 { body {
background-color: var(--color-background); background-color: var(--color-background);
color: var(--color-foreground); color: var(--color-foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
line-height: 1.5; line-height: 1.5;
overflow-wrap: break-word;
font-optical-sizing: auto; font-optical-sizing: auto;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
/* Niente pull-to-refresh accidentale né rimbalzo che scopre il fondo. */ /* Niente pull-to-refresh accidentale né rimbalzo che scopre il fondo. */
+33
View File
@@ -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");
+56
View File
@@ -12,6 +12,8 @@ import {
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
totaliSquadraValutazioni,
totaliTecnici,
type Azione, type Azione,
type Valutazione, type Valutazione,
type ScoutMatch, type ScoutMatch,
@@ -67,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 }); 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 ----------------- // --- giocatoriConScout: solo MVP da scout, presenze separate -----------------
const base = giocatori.find((g) => g.id === "g1")!; const base = giocatori.find((g) => g.id === "g1")!;
const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome }); const conScout = giocatoriConScout([match("m1", 3, 0, azioni)], { m1: base.nome });