10 Commits
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
davideandClaude Sonnet 5.5 7b93f92965 Versione 1.6.1
Changelog aggiornato con le modifiche dal tag v1.6.0: scout leggibile
nella pagina partita, classifica cacche completa, pagelle senza assenti,
pagina partita a sezioni a scomparsa. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:53:48 +01:00
davideandClaude Sonnet 5.5 c98e1ef218 Pagina partita: sezioni a scomparsa
Formazioni, badge, sondaggio, pagelle, scout e rosa diventano sezioni a
scomparsa; aperti all'inizio solo rosa e presenze e il sondaggio nella
sua finestra. Le formazioni indicano la fonte «Livescore CSI», lo scout
il numero di valutazioni. Titoli allineati a 20px e niente vuoto sotto
le sezioni chiuse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:47:36 +01:00
davideandClaude Sonnet 5.5 e6cfb1bf00 Pagelle: esclusi assenti e infortunati della partita
Nell'elenco da votare non compaiono i convocati che hanno risposto
«assente» o «infortunato» a quella partita; per le altre partite
restano votabili come prima. Solo filtro di interfaccia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:41:20 +01:00
davideandClaude Sonnet 5.5 30fb8e23b6 Scout: risultati leggibili nella pagina partita
La sezione «Scout» mostra le valutazioni − / + nell'app, non solo nel CSV:
percentuale di + e conteggi per fondamentale, selettore Totale / Set 1-5,
tabella per giocatore e guida (i) 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.

Aggiunge sintesiValutazioni() in scout-store con test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:38:35 +01:00
davideandClaude Sonnet 5.5 91ac7ce65c Sondaggio cacche: classifica completa per tutti i giocatori
Elenco con posizioni a pari merito senza salti, barra proporzionale
e riga personale evidenziata, anche a sondaggio chiuso.
Sezione «Report tecnico» della partita rinominata «Scout».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 23:25:50 +01:00
22 changed files with 768 additions and 106 deletions

No files matched your search

+5 -1
View File
@@ -9,7 +9,11 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale ## 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`, 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
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si «Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
+44
View File
@@ -13,6 +13,50 @@ 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
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 ## [1.6.0] - 2026-11-02
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`, Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
+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.
+2
View File
@@ -26,6 +26,8 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
partita (scout salvato o dato CSI). partita (scout salvato o dato CSI).
- Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia - Ogni convocato può votare tutti gli altri convocati, mai se stesso — escluso sia in UI sia
dal vincolo DB. 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ù - `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente. volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su - `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
+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.
+16 -9
View File
@@ -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 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**: «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 `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 `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) `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 `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 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 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» - 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ù. 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 - Lo scout in corso mostra solo il set in corso: le valutazioni dei set chiusi si rivedono solo
scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella nella pagina partita, dopo «Fine partita».
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.
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica - Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
garanzia è il lock di sessione (vedi sopra). garanzia è il lock di sessione (vedi sopra).
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock: - Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
@@ -171,6 +179,5 @@ calcolati a runtime).
## Evoluzioni possibili ## Evoluzioni possibili
- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin) - Nello scout, in sola lettura, le valutazioni dei set chiusi.
e, nello scout, in sola lettura quelle dei set chiusi.
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale. - Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
+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.0", "version": "1.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "crapp", "name": "crapp",
"version": "1.6.0", "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.0", "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.
+290
View File
@@ -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>
);
}
+66 -15
View File
@@ -40,11 +40,15 @@ export function SondaggioCacche({
const media = mediaPartita(righe, eventoId); const media = mediaPartita(righe, eventoId);
const classifica = [...dellaPartita] const classifica = [...dellaPartita]
.sort((a, b) => b.quantita - a.quantita) .sort((a, b) => b.quantita - a.quantita)
.slice(0, 3)
.map((r) => { .map((r) => {
const g = squadra.find((g) => g.id === r.giocatore_id); const g = squadra.find((g) => g.id === r.giocatore_id);
return { ...r, nome: g ? nomeCompleto(g) : "—" }; 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) { async function rispondi(quantita: number) {
if (!io) return; if (!io) return;
@@ -89,6 +93,7 @@ export function SondaggioCacche({
? "Sondaggio chiuso: era aperto fino al fischio d'inizio." ? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."} : "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
</p> </p>
<Classifica righe={classifica} io={io?.id} media={media} />
</Card> </Card>
); );
} }
@@ -126,19 +131,7 @@ export function SondaggioCacche({
))} ))}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]"> <Classifica righe={classifica} io={io?.id} media={media} />
<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>
{admin ? ( {admin ? (
<button <button
@@ -154,3 +147,61 @@ export function SondaggioCacche({
</Card> </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>
);
}
+8 -3
View File
@@ -310,11 +310,14 @@ export function SezioneTendina({
<button <button
type="button" type="button"
onClick={() => setAperta((v) => !v)} 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-expanded={aperta}
aria-controls={id} 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"> <span className="flex shrink-0 items-center gap-2">
{azione} {azione}
<ChevronDown <ChevronDown
@@ -336,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
+58
View File
@@ -96,6 +96,24 @@ export function contaValutazioni(
return out; 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 * 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. * terra): quello che compare nel suo riquadro dello scout.
@@ -273,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.
+41 -68
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react"; import { MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
import { cn } from "@/lib/utils"; 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 { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi"; import { convocatiEvento, useEvento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa"; 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 { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/crapp/DettaglioCsi";
import { Pagelle } from "@/components/crapp/Pagelle"; import { Pagelle } from "@/components/crapp/Pagelle";
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche"; import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
import { sondaggioAperto } from "@/lib/cacche";
import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { ScoutReport } from "@/components/crapp/ScoutReport";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store"; import { useSonoAllenatore } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
@@ -90,7 +91,6 @@ function PartitaDetail() {
: csiMatch : csiMatch
? matchDaPartitaCsi(csiMatch) ? matchDaPartitaCsi(csiMatch)
: undefined; : undefined;
const totaliTeam = scout ? totaliSquadra([scout]) : null;
const avversario = evento.titolo.includes(" vs ") const avversario = evento.titolo.includes(" vs ")
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo) ? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
: evento.titolo; : evento.titolo;
@@ -206,9 +206,16 @@ function PartitaDetail() {
) : null} ) : null}
{csiMatch ? ( {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} /> <DettaglioCsiEsteso matchId={csiMatch.id} avversario={avversario} />
</Section> </SezioneTendina>
) : null} ) : null}
<Section titolo="MVP eletto dalla squadra"> <Section titolo="MVP eletto dalla squadra">
@@ -220,9 +227,9 @@ function PartitaDetail() {
{match ? ( {match ? (
// Badge e cacche non si mostrano all'allenatore (DD-034). // Badge e cacche non si mostrano all'allenatore (DD-034).
sonoAllenatore ? null : ( sonoAllenatore ? null : (
<Section titolo="Badge votati dai compagni"> <SezioneTendina titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} /> <VotoSocial matchId={match.id} />
</Section> </SezioneTendina>
) )
) : ( ) : (
<Section titolo="In programma"> <Section titolo="In programma">
@@ -238,83 +245,49 @@ function PartitaDetail() {
</Section> </Section>
{sonoAllenatore ? null : ( {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} /> <SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section> </SezioneTendina>
)} )}
{match ? ( {match ? (
<Section titolo="Pagelle di fine partita"> <SezioneTendina titolo="Pagelle di fine partita">
<Pagelle matchId={match.id} convocati={convocati} chiuse={evento.pagelleChiuse} /> <Pagelle
</Section> matchId={match.id}
convocati={convocati.filter(
(g) => risposte[g.id] !== "assente" && risposte[g.id] !== "infortunato",
)}
chiuse={evento.pagelleChiuse}
/>
</SezioneTendina>
) : null} ) : null}
{scout && totaliTeam ? ( {scout ? (
<Section titolo="Report tecnico"> <SezioneTendina
<Card> titolo="Scout"
<div className="grid grid-cols-4 gap-2 text-center"> azione={
{[ scout.valutazioni.length > 0 ? (
{ l: "Punti", v: totaliTeam.punti }, <span className="text-[13px] font-bold tabular-nums text-muted-foreground">
{ l: "Ace", v: totaliTeam.ace }, {scout.valutazioni.length} valutazioni
{ 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>
<span className="tabular-nums text-muted-foreground"> ) : null
{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 <ScoutReport scout={scout} rosa={rosa} admin={admin} />
</button> </SezioneTendina>
) : null}
</Card>
</Section>
) : null} ) : null}
<Section titolo="Rosa e presenze"> <SezioneTendina titolo="Rosa e presenze" defaultAperta>
<RosaPresenze <RosaPresenze
eventoId={evento.id} eventoId={evento.id}
data={evento.data} data={evento.data}
tipo={evento.tipo} tipo={evento.tipo}
convocati={evento.convocati} convocati={evento.convocati}
/> />
</Section> </SezioneTendina>
</> </>
); );
} }
+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");
+73
View File
@@ -6,11 +6,14 @@ import {
contaPiuSenzaRicezione, contaPiuSenzaRicezione,
haPunteggio, haPunteggio,
parzialiFinali, parzialiFinali,
sintesiValutazioni,
contaValutazioni, contaValutazioni,
fondamentali, fondamentali,
giocatoriConScout, giocatoriConScout,
totaliPerGiocatore, totaliPerGiocatore,
totaliSquadra, totaliSquadra,
totaliSquadraValutazioni,
totaliTecnici,
type Azione, type Azione,
type Valutazione, type Valutazione,
type ScoutMatch, 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 }); 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 });
@@ -197,3 +254,19 @@ assert.equal(
}), }),
true, 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");