5 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 29acb326a7 Porta la versione a 1.3.0: changelog e stato del progetto
Sezione 1.3.0 del CHANGELOG con tutto quanto dopo v1.2.3 (notifica di compleanno,
obiettivi cliccabili, presenze sui convocati, avvisi in Home, caratteri più
grandi, tessera CSI tolta dalla dashboard). Versione in package.json e
package-lock.json, PROJECT_STATE aggiornato. Migration da applicare: m31.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:54:28 +02:00
davideandClaude Sonnet 5.5 77e5daaa7a Caratteri più grandi in tutta l'app (DD-049); etichetta «Cacche» nella rosa
Ogni testo sotto i 16 px sale di un passo in tutte le pagine (12→13, 13→14,
14→15, 15→16); restano invariate le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge da 10-11 px. Nella scheda giocatore di Squadra
l'etichetta «Cacche/partita» diventa «Cacche» (il valore resta la media).
Documentazione: DD-049, CHANGELOG, ARCHITECTURE, PROJECT_STATE, moduli squadra
e allenatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:50:28 +02:00
davideandClaude Sonnet 5.5 519f9aaff4 Avvisi in Home: componente condiviso, più leggibili; turno palloni ridisegnato
Certificati e turno palloni usano lo stesso componente Avviso: icona propria per
ogni caso (il colore non è l'unico segnale), testo più grande, elenchi di persone
in righe con dettaglio a destra e conteggio, freccia sulle card cliccabili,
comparsa graduale. Il turno palloni ha la pillola «Oggi»/«In arrivo» e i
messaggi in riquadri. Solo aspetto: testi e regole di comparsa invariati.
Test del componente, CHANGELOG, ARCHITECTURE e test/README aggiornati.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:44:58 +02:00
davideandClaude Sonnet 5.5 e7ae2c1884 Toglie numero e data tessera dalla dashboard (DD-048); docs allineate
Spariscono dalla scheda del giocatore in /admin il blocco «Tesseramento CSI» e il
salvataggio, il badge «Tesserato/Da tesserare» e il riquadro «Tesserati» della
tab Squadra. L'export CSV non cambia. Le colonne numero_tessera e data_tessera
restano a database, non più usate; nessuna migration.

Aggiornati PROJECT_STATE, CHANGELOG, ARCHITECTURE, DATABASE, ROADMAP, i moduli
profilo-giocatore, squadra e obiettivi-squadra, test/README e l'indice delle
decisioni.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:39:11 +02:00
davideandClaude Sonnet 5.5 c2c3b249f2 Obiettivi cliccabili con card di dettaglio; presenze del mese sui convocati
Ogni obiettivo di squadra apre un drawer che spiega come si calcola, cosa conta,
cosa è una presenza persa, cosa resta fuori, periodo, fonte dei dati ed esempio.
«90% di presenze del mese» usa ora i convocati dell'evento come posti (tutta la
rosa se non ce ne sono), come le presenze collettive: chi non è convocato non
abbassa più la percentuale. Test unit, card e integrazione; docs aggiornate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:58 +02:00
56 changed files with 1532 additions and 605 deletions

No files matched your search

+4 -4
View File
@@ -9,9 +9,9 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale
- **Versione:** 1.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
(3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
- **Versione:** 1.3.0 (tag `v1.3.0` su `main`) — notifica di compleanno, obiettivi cliccabili con card di
dettaglio, presenze calcolate sui convocati, avvisi in Home ridisegnati, caratteri più grandi, tessera CSI
tolta dalla dashboard. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
Project Ref `hetycilxgkdmccelwerq` non si usano più.
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
@@ -20,7 +20,7 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
è su `develop`, non ancora su `main`.
è su `main` dalla 1.3.0.
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
collega al primo accesso (DD-018).
+5 -1
View File
@@ -85,9 +85,13 @@ quando il database non risponde.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
schermate, pochi click.
**Caratteri** (DD-049): niente testo sotto i 13 px, tranne la navigazione a pillola e i badge
da 10-11 px. Scala in uso: 13 px note, 15 px corpo, 16 px titoli di card; si usano le misure
fisse `text-[13px]`…`text-[16px]`, non `text-xs` e `text-sm`.
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
+31 -2
View File
@@ -13,19 +13,48 @@ versioni `0.y.z` erano pre-release.
## [Non rilasciato]
## [1.3.0] - 2026-10-06
Gli auguri di compleanno arrivano da soli, gli obiettivi di squadra si possono toccare per capire come funzionano
e le presenze si contano solo sugli eventi a cui si è convocati. In tutta l'app i caratteri sono più grandi e gli
avvisi in Home più chiari. La migration `m31_notifica_compleanno` è **da applicare in produzione** (`npx supabase
db push`); il resto non tocca il database.
### Aggiunto
- **Notifica di compleanno** — ogni giorno, dalle 8:00, il festeggiato riceve un messaggio di auguri e tutti gli altri membri
attivi, allenatori compresi, il solo avviso «Oggi è il compleanno di …»; con più compleanni lo stesso giorno un solo
messaggio a testa. Push, centro notifiche ed email. Il 29 febbraio non è gestito (DD-047, issue #11). Migration
`m31_notifica_compleanno`, da applicare in produzione.
- **Obiettivi cliccabili** — toccando un obiettivo (in Squadra o nella card della Home) si apre una card che
spiega tutto: come si calcola, cosa conta e cosa no, cosa è una presenza persa, periodo di validità, fonte dei dati,
un esempio con numeri. Nessuna migration.
### Modificato
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
- **Obiettivo «90% di presenze del mese»** — i posti sono i convocati di ogni partita e allenamento (tutta la rosa se
non ce ne sono), come nelle presenze collettive: chi non è convocato non abbassa più la percentuale. Assente, forse,
infortunato e nessuna risposta restano presenze perse. Nessuna migration.
- **Obiettivo «Presenze collettive»** — sostituisce «250 presenze complessive»: è la percentuale di presenze di tutta la
rosa su allenamenti e partite definiti in stagione (passati e futuri, posti = convocati), con target 90%. Nessuna migration.
- **Obiettivo «1 evento di squadra al mese»** — si completa quando arriva l'ora dell'evento, non appena viene
creato. Un evento futuro non conta finché la sua data e ora non sono passate. Nessuna migration.
- **Avvisi in Home più leggibili** — certificati e turno palloni usano lo stesso componente (`Avviso`): icona
propria per ogni caso (non solo il colore), titolo e testo più grandi, elenchi di persone in righe con il dettaglio
a destra e il conteggio, freccia dove la card porta ai documenti, comparsa graduale. Il turno palloni ha la pillola
«Oggi»/«In arrivo» e i messaggi in riquadri. Solo aspetto: testi e regole di quando compaiono non cambiano.
Nessuna migration.
- **Caratteri più grandi in tutta l'app** — ogni testo sotto i 16 px sale di un passo (12→13, 14→15, ecc.), nelle
card, negli elenchi, nelle statistiche e nei drawer. Restano uguali le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge minuscoli. Solo aspetto, nessuna migration (DD-049).
- **Etichetta «Cacche» nella rosa** — nella scheda del giocatore in Squadra la riga «Cacche/partita» si chiama
«Cacche»; il valore resta la media per partita. Nessuna migration.
### Rimosso
- **Tessera CSI dalla dashboard** — in Profili spariscono il blocco «Tesseramento CSI» (numero e data
tessera), il badge «Tesserato»/«Da tesserare» e il riquadro «Tesserati» della tab Squadra. L'export CSV non
cambia. Le colonne `numero_tessera` e `data_tessera` restano a database, non più usate (DD-048). Nessuna migration.
## [1.2.3] - 2026-10-04
+1 -1
View File
@@ -59,7 +59,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
| `numero_tessera`, `data_tessera` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include tra i campi bloccati per chi reclama il proprio slot. |
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
| `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. |
+2 -2
View File
@@ -29,8 +29,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
resta un'estensione futura
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
+37
View File
@@ -0,0 +1,37 @@
# DD-048 — Via numero e data tessera dalla dashboard
**Data:** 6 ottobre 2026
**Stato:** Accettata (nessuna migration)
**Contesto**
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
**Decisione**
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
amministrativo: documento, certificato, dati personali.
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
già inseriti restano a database.
**Alternative scartate**
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
già inseriti; si può fare dopo senza toccare l'interfaccia.
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
`0/N`, un'informazione falsa.
**Conseguenze**
- L'admin non può più registrare né correggere la tessera dall'app.
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
**Riesame**
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
migration e togliere i due campi dal tipo e dalla lettura.
+53
View File
@@ -0,0 +1,53 @@
# DD-049 — Caratteri più grandi in tutta l'app
**Data:** 6 ottobre 2026
**Stato:** Accettata (solo interfaccia, nessuna migration)
**Contesto**
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
**Decisione**
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
| Prima | Dopo |
| ----------------- | ----- |
| `text-xs` (12 px) | 13 px |
| 13 px | 14 px |
| 14 px | 15 px |
| `text-sm` (14 px) | 15 px |
| 15 px | 16 px |
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
(conteggio sulla campanella).
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
l'aspetto, non i testi né le regole su quando un avviso compare.
**Alternative scartate**
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
barre a pillola, e ogni schermata andrebbe ricontrollata.
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
il testo andrebbe a capo o verrebbe troncato.
**Conseguenze**
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
titoli di card.
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
**Riesame**
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
contenitori stretti, senza toccare il resto.
+2
View File
@@ -86,6 +86,8 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
| [DD-047](DD-047.md) | Notifica di compleanno |
| [DD-046](DD-046.md) | Avviso certificati mancanti |
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
+1 -1
View File
@@ -97,7 +97,7 @@ nessuna schermata:
| Dove | Cosa si nasconde all'allenatore |
| ----------------------------------- | -------------------------------------------------------------------------------- |
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
+30 -13
View File
@@ -2,7 +2,8 @@
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`),
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
---
@@ -39,7 +40,7 @@ corrente dinamico (vedi sotto).
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
@@ -51,7 +52,12 @@ corrente dinamico (vedi sotto).
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
primo obiettivo non completato).
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
@@ -65,7 +71,9 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
seguono di conseguenza.
seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono
presenze perse.
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
@@ -75,8 +83,9 @@ passaggio a completato compare al refresh successivo.
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
conferme). Differisce da `o1` per periodo (stagione, non mese) e perché i posti sono i convocati
di ciascun evento, non sempre l'intera rosa. Non c'è un confine di stagione esplicito: conta ciò
conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i
convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione
`percentualePresenze()`. Non c'è un confine di stagione esplicito: conta ciò
che c'è in `eventi_app`.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
@@ -156,14 +165,22 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
da un backend reale, non solo funzione pura con contesto costruito a mano).
| Obiettivi | Unit test | Integration test |
| ---------- | ----------------------------- | -------------------------------------------------------- |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
| Obiettivi | Unit test | Integration test |
| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) |
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti
contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa,
confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia
del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test
diventa rosso.
- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi,
«Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato
in corso/completato.
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
+3 -8
View File
@@ -328,7 +328,6 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
@@ -336,7 +335,6 @@ Azioni disponibili.
- Scarica certificato
- Scarica documento
- Modifica dati squadra e dati personali del giocatore (DD-017)
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
- Scollega account, per liberare uno slot assegnato per errore
- Aggiungi giocatore, per inserire un nuovo membro della squadra
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
@@ -362,12 +360,9 @@ Campi esportati.
## Tracciamento tesseramento
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
complessivo della squadra.
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
## Completamento profilo
+7 -8
View File
@@ -48,20 +48,19 @@ gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)):
| Azione | Hook | Effetto |
| ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
| Azione | Hook | Effetto |
| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "crapp",
"version": "1.2.3",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crapp",
"version": "1.2.3",
"version": "1.3.0",
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.2.3",
"version": "1.3.0",
"private": true,
"sideEffects": false,
"type": "module",
+127
View File
@@ -0,0 +1,127 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { Reveal } from "@/components/motion/Reveal";
import { cn } from "@/lib/utils";
/**
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
* sua e un titolo, così si distingue anche senza vedere i colori.
*/
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
const toni: Record<TonoAvviso, string> = {
critico: "bg-destructive text-destructive-foreground",
attenzione: "bg-warning text-warning-foreground",
scaduto: "bg-primary text-primary-foreground",
azione: "bg-accent-grad text-accent-foreground",
};
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
export type RigaAvviso = { chi: string; cosa: string };
type ProprietaCorpo = {
tono: TonoAvviso;
icona: LucideIcon;
titolo: string;
/** Frasi di spiegazione, una per paragrafo. */
testi?: string[];
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
elenco?: RigaAvviso[];
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
cliccabile?: boolean;
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
etichetta?: string;
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
riquadri?: boolean;
};
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
export function CorpoAvviso({
tono,
icona: Icona,
titolo,
testi = [],
elenco = [],
cliccabile = false,
etichetta,
riquadri = false,
}: ProprietaCorpo) {
return (
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
<div className="flex items-center gap-3">
<span
aria-hidden
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
>
<Icona className="h-6 w-6" />
</span>
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
{titolo}
</p>
{etichetta ? (
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
{etichetta}
</span>
) : null}
{elenco.length > 1 ? (
<span
aria-label={`${elenco.length} persone`}
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
>
{elenco.length}
</span>
) : null}
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
</div>
{testi.map((t) => (
<p
key={t}
className={cn(
"mt-3 text-[15px] leading-snug",
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
)}
>
{t}
</p>
))}
{elenco.length > 0 ? (
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
{elenco.map((r) => (
<li
key={`${r.chi}-${r.cosa}`}
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
>
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
*/
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
const card = <CorpoAvviso {...corpo} />;
return (
<Reveal indice={indice} className="mx-5 mt-4">
{corpo.cliccabile ? (
<Link
to="/profilo"
search={{ tab: "documenti" }}
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
{card}
</Link>
) : (
<div role="status">{card}</div>
)}
</Reveal>
);
}
+28 -61
View File
@@ -1,5 +1,5 @@
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { CalendarClock, FileClock, FileX } from "lucide-react";
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificatiUtente,
@@ -12,45 +12,6 @@ import { oggiISO } from "@/lib/palloni-core";
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
function Card({
colore,
titolo,
righe,
cliccabile,
}: {
colore: "warning" | "primary" | "destructive";
titolo: string;
righe: string[];
cliccabile?: boolean;
}) {
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
colore === "warning"
? "bg-warning text-warning-foreground"
: colore === "destructive"
? "bg-destructive text-destructive-foreground"
: "bg-primary text-primary-foreground"
} ${cliccabile ? "premi" : ""}`;
const contenuto = (
<>
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> {titolo}
</p>
{righe.map((r) => (
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
{r}
</p>
))}
</>
);
return cliccabile ? (
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
{contenuto}
</Link>
) : (
<div className={classi}>{contenuto}</div>
);
}
/**
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
@@ -72,28 +33,31 @@ export function AvvisoCertificati() {
base,
});
const cardPersonale = personaleMancante ? (
<Card
colore="destructive"
<Avviso
tono="critico"
icona={FileX}
titolo="Certificato mancante"
cliccabile
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
/>
) : personale ? (
personale.giorni < 0 ? (
<Card
colore="primary"
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificato scaduto"
cliccabile
righe={[
testi={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Card
colore="warning"
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificato in scadenza"
cliccabile
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
/>
)
) : null;
@@ -104,30 +68,33 @@ export function AvvisoCertificati() {
<>
{cardPersonale}
{mancanti.length > 0 ? (
<Card
colore="destructive"
<Avviso
tono="critico"
icona={FileX}
titolo="Certificati mancanti"
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
/>
) : null}
{scaduti.length > 0 ? (
<Card
colore="primary"
<Avviso
tono="scaduto"
icona={FileClock}
titolo="Certificati scaduti"
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Card
colore="warning"
<Avviso
tono="attenzione"
icona={CalendarClock}
titolo="Certificati in scadenza"
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
}
+13 -11
View File
@@ -33,7 +33,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
<DrawerDescription className="mt-1 text-sm leading-relaxed">
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{def.descrizione}
</DrawerDescription>
</div>
@@ -42,13 +42,15 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
{stato ? (
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Stato attuale
</p>
{grado ? (
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
{meta?.label}
</span>
) : (
<span className="text-xs font-bold uppercase text-muted-foreground">
<span className="text-[13px] font-bold uppercase text-muted-foreground">
In progresso
</span>
)}
@@ -59,19 +61,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
{stato.prossimaSoglia ? (
<>
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
</p>
</>
) : (
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
<p className="mt-2 text-[13px] text-success">Hai raggiunto il livello massimo.</p>
)}
</div>
) : null}
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Soglie
</p>
<div className="grid grid-cols-3 gap-2">
@@ -88,7 +90,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
<p
className={cn(
"text-xs font-bold uppercase",
"text-[13px] font-bold uppercase",
raggiunto ? gm.text : "text-muted-foreground",
)}
>
@@ -102,7 +104,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
{def.soglie[g]}
</p>
<p className="text-xs text-muted-foreground">{def.unita}</p>
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
</div>
);
})}
@@ -110,7 +112,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
</div>
{def.celebrazione ? (
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
“{def.celebrazione}”
</p>
) : null}
@@ -142,7 +144,7 @@ export function BadgeDrawer({
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
+3 -3
View File
@@ -51,11 +51,11 @@ export function CelebrazioneBadge() {
{notifica.emoji}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-accent">
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
</p>
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
<p className="mt-1.5 text-sm text-muted-foreground">{notifica.testo}</p>
<p className="mt-1.5 text-[15px] text-muted-foreground">{notifica.testo}</p>
</div>
<button
type="button"
@@ -69,7 +69,7 @@ export function CelebrazioneBadge() {
<button
type="button"
onClick={chiudi}
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-[15px] font-bold uppercase tracking-wide text-accent-foreground"
>
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
</button>
+28 -24
View File
@@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
<div className="flex items-center justify-between">
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
{meta ? (
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
<span className={cn("text-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
) : null}
</div>
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
{b.valore} {b.def.unita}
</p>
{b.prossimaSoglia ? (
<>
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
<p className="mt-1 text-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
</>
) : (
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
)}
</Reveal>
);
@@ -90,13 +90,15 @@ function SocialDrawer({
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
<DrawerDescription className="mt-1 text-sm leading-relaxed">
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{cat.descrizione}
</DrawerDescription>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Vinto
</p>
<p className="mt-1 font-display text-3xl leading-none">
{conteggio}{" "}
<span className="text-lg text-muted-foreground">
@@ -109,7 +111,7 @@ function SocialDrawer({
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
@@ -139,7 +141,7 @@ export function CollezioneBadge({
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
Collezione badge
</p>
<p className="font-display text-4xl leading-none">
@@ -147,7 +149,7 @@ export function CollezioneBadge({
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
</p>
</div>
<p className="text-xs text-primary-foreground/70">
<p className="text-[13px] text-primary-foreground/70">
<Numero valore={pct} suffisso="%" /> completata
</p>
</div>
@@ -156,12 +158,12 @@ export function CollezioneBadge({
{vicino ? (
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
<p className="text-xs font-bold uppercase tracking-wide text-accent">
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
Prossimo traguardo
</p>
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
</p>
</div>
@@ -169,7 +171,7 @@ export function CollezioneBadge({
{c.sbloccati.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Sbloccati ({c.sbloccati.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -184,7 +186,7 @@ export function CollezioneBadge({
{c.inProgresso.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
In progresso ({c.inProgresso.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -199,7 +201,7 @@ export function CollezioneBadge({
{socialVinti.length > 0 ? (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Votati dai compagni
</p>
<div className="grid grid-cols-2 gap-2">
@@ -207,8 +209,8 @@ export function CollezioneBadge({
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-accent/25">
<p className="text-lg leading-none">{cat.emoji}</p>
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1 text-[15px] font-bold leading-tight">{cat.nome}</p>
<p className="text-[13px] text-muted-foreground">
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
</p>
</div>
@@ -219,7 +221,7 @@ export function CollezioneBadge({
) : null}
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Badge segreti
</p>
<div className="grid grid-cols-2 gap-2">
@@ -229,8 +231,8 @@ export function CollezioneBadge({
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-oro/40">
<Icon className="h-6 w-6 text-oro" />
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
{b.def.celebrazione ?? "Badge segreto sbloccato."}
</p>
</div>
@@ -243,8 +245,10 @@ export function CollezioneBadge({
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
>
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
<p className="mt-1.5 text-[15px] font-bold leading-tight text-muted-foreground">
???
</p>
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
</div>
))}
</div>
+19 -17
View File
@@ -49,7 +49,7 @@ export function MetaPartitaCsi({
.join(" · ");
if (!voci && !link) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
{voci ? <span>{voci}</span> : null}
{link ? (
<a
@@ -68,17 +68,17 @@ export function MetaPartitaCsi({
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
return (
<div>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{squadra.squadra}
</p>
<div className="mt-2 space-y-1">
{squadra.titolari.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
<div key={i} className="flex items-center gap-2 text-[15px]">
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
{g.numero}
</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
</div>
))}
</div>
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
</p>
<div className="mt-1 space-y-1">
{squadra.panchina.map((g, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<div key={i} className="flex items-center gap-2 text-[13px] text-muted-foreground">
<span className="w-6 shrink-0 text-center">{g.numero}</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
</div>
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
</>
) : null}
{squadra.staff.length > 0 ? (
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
<div className="mt-3 space-y-0.5 text-[13px] text-muted-foreground">
{squadra.staff.map((s, i) => (
<div key={i}>
{s.nome} · {s.ruolo}
@@ -121,7 +121,7 @@ function BarraPrecedenti({
}) {
const totale = noi + avversario || 1;
return (
<div className="text-xs">
<div className="text-[13px]">
<div className="flex items-center justify-between text-muted-foreground">
<span className="font-bold text-foreground">{noi}</span>
<span>{label}</span>
@@ -139,7 +139,7 @@ const NOME_NOI = "CRAP Volley";
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
function TestataSquadre({ avversario }: { avversario: string }) {
return (
<div className="flex items-center justify-between text-xs font-bold">
<div className="flex items-center justify-between text-[13px] font-bold">
<span className="truncate text-accent">{NOME_NOI}</span>
<span className="truncate text-right text-muted-foreground">{avversario}</span>
</div>
@@ -158,7 +158,7 @@ function BarraProbabilita({
const favoritaNoi = noi >= avversarioPct;
return (
<div>
<p className="mb-2 text-xs text-muted-foreground">
<p className="mb-2 text-[13px] text-muted-foreground">
Probabilità di vittoria (calcolo CSI):{" "}
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
@@ -171,13 +171,13 @@ function BarraProbabilita({
</div>
<div className="flex h-7 overflow-hidden rounded-full">
<div
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
className="flex items-center justify-center bg-success text-[13px] font-bold text-success-foreground"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
style={{ width: `${avversarioPct}%` }}
>
{avversarioPct.toFixed(0)}%
@@ -197,7 +197,7 @@ function BloccoPrecedenti({
return (
<div className="space-y-3">
<TestataSquadre avversario={avversario} />
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{precedenti.totale > 0
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
@@ -248,20 +248,22 @@ export function DettaglioCsiEsteso({
const { data, isLoading } = useCsiPartita(matchId);
if (isLoading) {
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
return (
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
);
}
if (!data || (!data.formazioni && !data.precedenti)) return null;
return (
<div className="space-y-4">
{data.giornata || data.nota ? (
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
</p>
) : null}
{data.formazioni ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -272,7 +274,7 @@ export function DettaglioCsiEsteso({
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
</div>
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
+9 -9
View File
@@ -101,10 +101,10 @@ export function EventoCard({
<Cake className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
<h3 className="truncate text-[15px] font-bold leading-tight">{evento.titolo}</h3>
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
</div>
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
</div>
</Card>
@@ -139,13 +139,13 @@ export function EventoCard({
<div className="flex items-start justify-between gap-3">
<span
className={cn(
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
"inline-block rounded-full px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-wide",
tipo.className,
)}
>
{tipo.label}
</span>
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
{etichettaData(evento.data)}
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
</span>
@@ -153,10 +153,10 @@ export function EventoCard({
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
{sottotitoloPartita ? (
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">{sottotitoloPartita}</p>
) : null}
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span>{evento.ora}</span>
@@ -174,12 +174,12 @@ export function EventoCard({
</div>
{noteCard ? (
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
) : null}
{metaStato ? (
<p
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
aria-live="polite"
>
<span aria-hidden>{metaStato.emoji}</span>
+142
View File
@@ -0,0 +1,142 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
import { Barra } from "@/components/motion/Barra";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
return (
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo}
</p>
<ul className="space-y-1.5">
{voci.map((v) => (
<li key={v} className="flex gap-2 text-[15px] leading-snug">
<span aria-hidden className="shrink-0 font-bold text-accent">
{segno}
</span>
<span>{v}</span>
</li>
))}
</ul>
</div>
);
}
function Blocco({ titolo, testo }: { titolo: string; testo: string }) {
return (
<div>
<p className="mb-1 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo}
</p>
<p className="text-[15px] leading-relaxed">{testo}</p>
</div>
);
}
/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
const d = o.dettaglio;
return (
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 pt-0">
<div className="flex items-start gap-4">
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-secondary text-2xl ring-1 ring-border">
{o.emoji}
</span>
<div className="min-w-0 flex-1">
<DrawerTitle className="text-xl font-bold leading-tight">{o.titolo}</DrawerTitle>
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
{o.descrizione}
</DrawerDescription>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<div className="flex items-center justify-between">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Stato attuale
</p>
<span
className={cn(
"text-[13px] font-bold uppercase",
fatto ? "text-success" : "text-muted-foreground",
)}
>
{fatto ? "Completato" : "In corso"}
</span>
</div>
<p className="mt-1 font-display text-3xl leading-none">
{o.valore} <span className="text-lg text-muted-foreground">su {o.target}</span>{" "}
<span className="text-lg text-muted-foreground">{o.unita}</span>
</p>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-[13px] text-muted-foreground">
{pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-[13px] font-semibold text-accent">{microcopyObiettivo(o)}</p>
</div>
<Blocco titolo="Come si calcola" testo={d.comeSiCalcola} />
<Elenco titolo="Cosa conta" voci={d.conta} segno="+" />
{d.perse ? <Elenco titolo="Presenza persa" voci={d.perse} segno="−" /> : null}
<Elenco
titolo={d.perse ? "Non entra nel calcolo" : "Cosa non conta"}
voci={d.nonConta}
segno={d.perse ? "·" : "−"}
/>
<Blocco titolo="Quando vale" testo={d.periodo} />
<Blocco titolo="Da dove arrivano i dati" testo={d.fonte} />
<Blocco titolo="Un esempio" testo={d.esempio} />
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
{o.impatto}
</p>
</div>
);
}
/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
export function ObiettivoDrawer({
obiettivo,
children,
}: {
obiettivo: ObiettivoSquadra;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="sr-only">
<DrawerTitle>{obiettivo.titolo}</DrawerTitle>
<DrawerDescription>{obiettivo.descrizione}</DrawerDescription>
</DrawerHeader>
<SchedaObiettivo o={obiettivo} />
<DrawerFooter>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
+12 -10
View File
@@ -45,19 +45,19 @@ export function Pagelle({
return (
<Card>
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
</p>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[13px] text-muted-foreground">
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
</p>
{isPending ? (
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
<p aria-busy="true" className="mt-3 text-[13px] text-muted-foreground">
Carico le pagelle…
</p>
) : (
@@ -70,15 +70,17 @@ export function Pagelle({
return (
<div key={g.id} className="rounded-2xl bg-secondary/60 p-2.5">
<div className="flex items-center gap-2.5">
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="text-xs text-muted-foreground">
<span className="block truncate text-[15px] font-bold leading-tight">
{g.nome}
</span>
<span className="text-[13px] text-muted-foreground">
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
</span>
</span>
{sonoIo ? (
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
<span className="shrink-0 text-[13px] font-semibold uppercase text-muted-foreground">
Sei tu
</span>
) : chiuse ? (
@@ -89,7 +91,7 @@ export function Pagelle({
disabled={!io || vota.isPending}
onClick={() => setApertoPer(aperto ? null : g.id)}
className={cn(
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
"shrink-0 rounded-full px-3 py-1 text-[13px] font-bold uppercase transition-colors disabled:opacity-50",
mio !== undefined
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
@@ -107,7 +109,7 @@ export function Pagelle({
type="button"
onClick={() => invia(g.id, v)}
className={cn(
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
"rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90",
mio === v
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
+13 -11
View File
@@ -27,7 +27,7 @@ const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
return (
<div className="flex items-center gap-2 pt-2">
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
<h3 className="font-display text-[15px] uppercase tracking-wide">{titolo}</h3>
{completa ? <Check className="h-4 w-4 text-success" /> : null}
</div>
);
@@ -67,7 +67,7 @@ function CampoFile({
return (
<div className="flex items-center justify-between gap-3 py-2">
<span className="flex min-w-0 items-center gap-2 text-sm">
<span className="flex min-w-0 items-center gap-2 text-[15px]">
<span
className={cn(
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
@@ -93,7 +93,7 @@ function CampoFile({
type="button"
onClick={() => input.current?.click()}
disabled={inCorso}
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
className="premi rounded-xl bg-primary px-3 py-2 text-[13px] font-bold text-primary-foreground disabled:opacity-60"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
</button>
@@ -336,7 +336,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
style={{ width: `${perc}%` }}
/>
</div>
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
{perc}%
</span>
</div>
@@ -366,7 +366,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
type="button"
onClick={() => void salva()}
disabled={!sporco || inCorso}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="premi 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 disabled:opacity-50"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
@@ -430,7 +430,7 @@ export function ProfiloAmministrativo({
style={{ width: `${perc}%` }}
/>
</div>
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
{perc}%
</span>
</div>
@@ -472,7 +472,7 @@ export function ProfiloAmministrativo({
type="button"
onClick={salvaBozza}
disabled={!sporco || salva.isPending}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="premi 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 disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
@@ -486,7 +486,9 @@ export function ProfiloAmministrativo({
<SezioneTendina
titolo="Dati per il tesseramento"
indice={indice}
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
azione={
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
}
>
{corpo}
</SezioneTendina>
@@ -521,10 +523,10 @@ export function CompletaProfilo({
className="premi block rounded-3xl bg-card p-4 shadow-card"
>
<div className="flex items-center justify-between gap-3">
<span className="font-display text-[15px] uppercase tracking-wide">
<span className="font-display text-[16px] uppercase tracking-wide">
Completa il tuo profilo
</span>
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
@@ -532,7 +534,7 @@ export function CompletaProfilo({
style={{ width: `${perc}%` }}
/>
</div>
<p className="mt-2 text-[13px] text-muted-foreground">
<p className="mt-2 text-[14px] text-muted-foreground">
{allenatore
? "Data e luogo di nascita, telefono ed email."
: "Documento e certificato medico servono per il tesseramento CSI."}
+13 -11
View File
@@ -1,4 +1,5 @@
import { AlertCircle } from "lucide-react";
import { Volleyball } from "lucide-react";
import { Avviso } from "@/components/crapp/Avviso";
import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
@@ -17,6 +18,8 @@ export function PromemoriaPalloni() {
const oggi = oggiISO();
const messaggi: string[] = [];
// «Oggi» solo se c'è davvero qualcosa da fare oggi; altrimenti è un semplice promemoria.
let oggiTocca = true;
for (const evento of lista) {
if (evento.data !== oggi) continue;
@@ -36,6 +39,7 @@ export function PromemoriaPalloni() {
}
if (messaggi.length === 0) {
oggiTocca = false;
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
if (!prossimo) return null;
messaggi.push(
@@ -44,15 +48,13 @@ export function PromemoriaPalloni() {
}
return (
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
<AlertCircle className="h-5 w-5" /> Turno palloni
</p>
{messaggi.map((m) => (
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
{m}
</p>
))}
</div>
<Avviso
tono="azione"
icona={Volleyball}
titolo="Turno palloni"
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
testi={messaggi}
riquadri
/>
);
}
+11 -11
View File
@@ -70,7 +70,7 @@ export function RosaPresenze({
<div className="space-y-3">
<Card>
<div className="flex items-baseline justify-between">
<p className="text-sm font-bold">
<p className="text-[15px] font-bold">
Hanno risposto {risposteN}/{rosa.length}
</p>
<span className="font-display text-xl leading-none">{perc}%</span>
@@ -84,7 +84,7 @@ export function RosaPresenze({
<span
key={s}
className={cn(
"rounded-full px-2.5 py-1 text-xs font-bold",
"rounded-full px-2.5 py-1 text-[13px] font-bold",
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
)}
>
@@ -92,14 +92,14 @@ export function RosaPresenze({
</span>
);
})}
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
❔ {mancanti.length} da rispondere
</span>
</div>
{io ? (
<div className="mt-4 border-t border-border pt-3">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
@@ -114,7 +114,7 @@ export function RosaPresenze({
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
"rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(statoMeta[s].className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
@@ -133,7 +133,7 @@ export function RosaPresenze({
type="button"
onClick={sollecita}
disabled={sollecito || daSollecitare === 0 || passato}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
>
{sollecito ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -146,7 +146,7 @@ export function RosaPresenze({
</Card>
{isPending ? (
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
Carico le risposte…
</p>
) : null}
@@ -194,16 +194,16 @@ function Gruppo({
attenzione && "border border-dashed border-warning",
)}
>
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo} · {n}
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
</p>
<div className="mt-3 space-y-2">
{lista.map((g) => (
<div key={g.id} className="flex items-center gap-3">
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{g.nome}</span>
<span className="shrink-0 text-xs text-muted-foreground">{g.ruolo}</span>
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
</div>
))}
</div>
+1 -1
View File
@@ -37,7 +37,7 @@ export function ScoutEntry({
{occupato ? <Lock className="h-5 w-5 shrink-0" /> : <Radio className="h-5 w-5 shrink-0" />}
<span className="min-w-0 flex-1">
<span className="block font-display text-lg uppercase leading-none">{titolo}</span>
<span className="block text-xs opacity-80">{sottotitolo}</span>
<span className="block text-[13px] opacity-80">{sottotitolo}</span>
</span>
{disponibile ? <ChevronRight className="h-5 w-5 shrink-0" /> : null}
</>
+6 -6
View File
@@ -28,8 +28,8 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
<p className="text-xs text-muted-foreground">{s.def.descrizione}</p>
<p className="text-[15px] font-bold leading-tight">{s.def.label}</p>
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
</div>
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame
@@ -39,7 +39,7 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
</span>
</div>
<Barra percentuale={s.progresso} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""}
{s.messaggio}
</p>
@@ -61,17 +61,17 @@ export function SerieHome({ g }: { g: Giocatore }) {
<Icon className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">
<p className="text-[15px] font-bold leading-tight">
Serie {top.def.label.toLowerCase()}: {top.valore}
</p>
<p className="text-xs text-muted-foreground">{top.messaggio}</p>
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
</div>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{serieGiocatore(g).map((s) => (
<div key={s.def.tipo} className="rounded-2xl bg-secondary p-2 text-center">
<p className="font-display text-xl leading-none">{s.valore}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
{s.def.label}
</p>
</div>
+8 -8
View File
@@ -81,10 +81,10 @@ export function SondaggioCacche({
if (!sondaggioAperto(dataEvento, oraEvento)) {
return (
<Card>
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[13px] text-muted-foreground">
{sondaggioTerminato(dataEvento, oraEvento)
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
@@ -96,14 +96,14 @@ export function SondaggioCacche({
return (
<Card>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
{dellaPartita.length} risposte
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[13px] text-muted-foreground">
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
</p>
@@ -115,7 +115,7 @@ export function SondaggioCacche({
disabled={!io || salva.isPending}
onClick={() => rispondi(n)}
className={cn(
"min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
"min-w-11 rounded-xl px-3 py-2 text-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
mia?.quantita === n
? "bg-accent text-accent-foreground shadow-pop"
: "bg-secondary text-foreground",
@@ -126,7 +126,7 @@ export function SondaggioCacche({
))}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<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>
@@ -145,7 +145,7 @@ export function SondaggioCacche({
type="button"
onClick={avvisaTutti}
disabled={avviso}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
>
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
Avvisa tutti del sondaggio
+6 -6
View File
@@ -74,16 +74,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
<CircleDot className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="block text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Palloni
</span>
<span className="flex items-center gap-1.5 text-sm font-bold leading-tight">
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
{isPending || assegna.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
) : null}
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
{proposto && giocatore ? (
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs font-bold uppercase text-muted-foreground">
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[13px] font-bold uppercase text-muted-foreground">
proposto
</span>
) : null}
@@ -100,11 +100,11 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
type="button"
onClick={() => scegli(g.id)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[15px] transition-colors",
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
)}
>
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-[13px]" />
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
</button>
@@ -117,7 +117,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
type="button"
onClick={avvisa}
disabled={avviso}
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-[13px] font-bold uppercase text-foreground disabled:opacity-50"
>
{avviso ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
+9 -9
View File
@@ -46,10 +46,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
if (!votoMvpAperto(evento.data, evento.ora)) {
return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP
</p>
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[13px] text-muted-foreground">
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
</p>
</div>
@@ -75,20 +75,20 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
return (
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
<div className="flex items-center justify-between gap-2">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
</p>
<button
type="button"
onClick={() => setAperto((v) => !v)}
disabled={!puoVotare}
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{mio ? "Cambia voto" : "Vota"}
</button>
</div>
<p className="mt-2 text-xs">
<p className="mt-2 text-[13px]">
{testa && !pareggio ? (
<span className="inline-flex items-center gap-1 font-bold">
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
@@ -100,9 +100,9 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
)}
</p>
{mio ? (
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
) : !puoVotare ? (
<p className="mt-1 text-xs text-muted-foreground">
<p className="mt-1 text-[13px] text-muted-foreground">
Vota chi era presente a questa partita.
</p>
) : null}
@@ -120,7 +120,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
disabled={vota.isPending}
onClick={() => invia(g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
mio?.votato_id === g.id
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
@@ -133,7 +133,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
) : conteggio.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{conteggio.map((c) => (
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs font-semibold">
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-[13px] font-semibold">
{c.nome} · {c.voti}
</span>
))}
+10 -8
View File
@@ -51,7 +51,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
return (
<div className="space-y-2">
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs font-semibold text-muted-foreground">
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-[13px] font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent" />
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria
@@ -79,14 +79,16 @@ export function VotoSocial({ matchId }: { matchId: string }) {
{cat.emoji}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
<span className="block truncate text-xs text-muted-foreground">
<span className="block truncate text-[15px] font-bold leading-tight">
{cat.nome}
</span>
<span className="block truncate text-[13px] text-muted-foreground">
{mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione}
</span>
</span>
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
"shrink-0 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
)}
>
@@ -105,7 +107,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
disabled={vota.isPending}
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
mio?.votato_id === g.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
@@ -118,17 +120,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
) : (
<div className="border-t border-border px-3 py-2">
{totale === 0 ? (
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
Nessun voto ancora: apri e scegli un compagno.
</p>
) : vincitore ? (
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold">
<Crown className="h-3.5 w-3.5 text-oro" />
<Icon className="h-3.5 w-3.5 text-accent" />
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
</p>
) : (
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
Parità con {totale} voti: servono altri voti per assegnare il badge.
</p>
)}
+8 -8
View File
@@ -107,13 +107,13 @@ function RigaNotifica({
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) onElimina(notifica.id);
}}
className="touch-pan-y bg-card p-3 pr-8 text-sm"
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? (
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
) : null}
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
@@ -254,7 +254,7 @@ export function PageHeader({
<div className="min-w-0">
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
{sottotitolo ? (
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
) : null}
</div>
{azione ?? <LinkProfilo />}
@@ -338,7 +338,7 @@ export function SezioneTendina({
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
*/
export const classiInput =
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
/**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
@@ -349,7 +349,7 @@ export const classiInput =
export function Campo({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block min-w-0">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="mt-1 block">{children}</span>
@@ -378,7 +378,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
meta.className,
className,
)}
@@ -402,10 +402,10 @@ export function StatTile({
<p className="font-display text-2xl leading-none">
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
</p>
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
</div>
);
}
+1 -1
View File
@@ -78,7 +78,7 @@ const DrawerDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn("text-[15px] text-muted-foreground", className)}
{...props}
/>
));
-34
View File
@@ -222,40 +222,6 @@ export function useSalvaDatiSquadra() {
});
}
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
/**
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
* tesserato.
*/
export function useSalvaTesseramento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
const dati = {
numero_tessera: input.dati.numeroTessera?.trim() || null,
data_tessera: input.dati.dataTessera || null,
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return {
giocatoreId: input.giocatoreId,
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
};
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
/**
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
+196 -18
View File
@@ -14,6 +14,25 @@ export type ObiettivoSquadra = {
emoji: string;
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
impatto: string;
/** Spiegazione completa mostrata nella card di dettaglio: nessun punto oscuro. */
dettaglio: DettaglioObiettivo;
};
export type DettaglioObiettivo = {
/** Cosa misura l'obiettivo e come si arriva al numero mostrato, in parole semplici. */
comeSiCalcola: string;
/** Cosa fa salire il numero. */
conta: string[];
/** Cosa è una presenza persa: occupa un posto ma lo lascia vuoto, quindi abbassa la percentuale. */
perse?: string[];
/** Cosa resta fuori dal calcolo: non sale e non scende. */
nonConta: string[];
/** Quando l'obiettivo parte, quando si azzera o scade. */
periodo: string;
/** Da dove arrivano i dati e quando si aggiornano. */
fonte: string;
/** Esempio concreto con numeri. */
esempio: string;
};
export type ContestoObiettivi = {
@@ -67,28 +86,21 @@ function fineMese(oggi: Date): string {
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
const delMese = ctx.eventi.filter(
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
);
if (delMese.length === 0 || rosaSize === 0) return 0;
const posti = delMese.length * rosaSize;
const presenti = delMese.reduce((s, e) => {
const risposte = ctx.presenze[e.id] ?? {};
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
}, 0);
return Math.round((presenti / posti) * 100);
}
/**
* Presenze (presente o ritardo) su tutti gli allenamenti e le partite definiti, passati e
* futuri, sul totale dei posti: per ogni evento i convocati, o tutta la rosa se non ce ne sono.
* Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da
* `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non
* è convocato non occupa un posto, quindi la sua assenza non pesa.
*/
function percentualePresenzeCollettive(ctx: ContestoObiettivi, rosa: Giocatore[]) {
function percentualePresenze(
ctx: ContestoObiettivi,
rosa: Giocatore[],
filtro: (e: Evento) => boolean = () => true,
) {
let posti = 0;
let presenti = 0;
for (const e of ctx.eventi) {
if (e.tipo !== "partita" && e.tipo !== "allenamento") continue;
if (!filtro(e)) continue;
const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id));
posti += attesi.length;
const risposte = ctx.presenze[e.id] ?? {};
@@ -126,12 +138,35 @@ export function obiettiviSquadra(
id: "o1",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenzeMese(ctx, rosa.length, mese),
valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
target: 90,
unita: "%",
scadenza: fineMese(oggi),
emoji: "📣",
impatto: "Ogni sì in più alza la media di tutta la squadra.",
dettaglio: {
comeSiCalcola:
"Si guardano solo le partite e gli allenamenti del mese in corso. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi del mese.",
conta: [
"«Presente» o «in ritardo» di un convocato a una partita o a un allenamento del mese.",
"Vale anche per gli eventi futuri del mese: appena confermi, la percentuale sale.",
],
perse: [
"«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
"«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
],
nonConta: [
"Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
"Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
"Gli eventi degli altri mesi.",
],
periodo:
"Solo il mese in corso: si azzera il primo giorno di ogni mese e si chiude l'ultimo giorno del mese. Il mese dopo riparte da zero, con il suo titolo e la sua scadenza.",
fonte:
"Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
esempio:
"Ad agosto ci sono 2 allenamenti. Il primo ha 12 convocati e 10 rispondono «presente»: 10 posti pieni su 12. Il secondo ha 10 convocati e 9 «presenti»: 9 su 10. In tutto 19 posti pieni su 22, cioè 86%. I giocatori non convocati non sono nel conto: per arrivare al 90% servono almeno 20 posti pieni su 22.",
},
},
{
id: "o2",
@@ -143,16 +178,55 @@ export function obiettiviSquadra(
scadenza: fineMese(oggi),
emoji: "⚡",
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
dettaglio: {
comeSiCalcola:
"Per ogni evento in calendario ci sono tanti posti quanti sono i giocatori in rosa. Si contano le risposte date, di qualsiasi tipo, e si divide per tutti i posti.",
conta: [
"Qualsiasi risposta: presente, in ritardo o assente. Conta aver risposto, non cosa si è risposto.",
"Partite, allenamenti ed eventi di squadra.",
],
nonConta: [
"I compleanni: non richiedono risposta.",
"Un evento a cui non hai ancora risposto.",
],
periodo:
"Considera tutti gli eventi in programma, non solo quelli del mese. La scadenza indicata è la fine del mese corrente.",
fonte: "Eventi del calendario e risposte dei giocatori. Si aggiorna a ogni risposta.",
esempio:
"Con 14 giocatori e 10 eventi i posti sono 140. Con 126 risposte date, siamo al 90%.",
},
},
{
id: "o7",
titolo: "Presenze collettive",
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
valore: percentualePresenzeCollettive(ctx, rosa),
valore: percentualePresenze(ctx, rosa),
target: 90,
unita: "%",
emoji: "🤝",
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
dettaglio: {
comeSiCalcola:
"Si guardano tutte le partite e tutti gli allenamenti della stagione, passati e futuri. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi insieme.",
conta: [
"«Presente» o «in ritardo» di un convocato a una partita o a un allenamento.",
"Anche le conferme per gli eventi futuri: appena confermi, la percentuale sale.",
],
perse: [
"«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
"«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
],
nonConta: [
"Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
"Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
],
periodo:
"Tutta la stagione: non si azzera mai e non ha scadenza. Conta tutto ciò che c'è in calendario, quindi un evento cancellato smette di contare e uno nuovo aggiunge i suoi posti.",
fonte:
"Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
esempio:
"In stagione ci sono 3 eventi: un allenamento con 12 convocati e 10 presenti, uno con 10 convocati e 9 presenti, una partita con 14 convocati e 13 presenti. Posti pieni 32 su 36, cioè 89%: per il 90% manca una presenza in più. La differenza con quello del mese è solo il periodo: qui contano tutti gli eventi insieme, senza azzerarsi.",
},
},
{
id: "o12",
@@ -163,6 +237,19 @@ export function obiettiviSquadra(
unita: "di media",
emoji: "📝",
impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.",
dettaglio: {
comeSiCalcola:
"Media di tutti i voti dati dai giocatori nelle pagelle dopo le partite, arrotondata a una cifra decimale.",
conta: ["Ogni voto dato in una pagella, a qualsiasi compagno."],
nonConta: [
"Gli autovoti: non si può votare se stessi.",
"Le pagelle non compilate: non pesano sulla media.",
],
periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
esempio:
"Se i voti dati finora sono 7, 8 e 7, la media è 7.3: mancano 0.2 per arrivare a 7.5.",
},
},
{
id: "o13",
@@ -173,6 +260,16 @@ export function obiettiviSquadra(
unita: "voti",
emoji: "🗳️",
impatto: "Vota i compagni a fine partita: bastano due minuti.",
dettaglio: {
comeSiCalcola:
"Si contano tutti i voti dati nelle pagelle durante la stagione. Un voto è un giocatore che valuta un compagno dopo una partita.",
conta: ["Ogni singolo voto dato a un compagno, non ogni pagella intera."],
nonConta: ["Le pagelle non compilate.", "Gli autovoti, che non sono ammessi."],
periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
esempio:
"Se dopo una partita 10 giocatori votano 9 compagni ciascuno, la squadra aggiunge 90 voti.",
},
},
{
id: "o11",
@@ -183,6 +280,21 @@ export function obiettiviSquadra(
unita: "giocatori",
emoji: "🔗",
impatto: "Tieni viva la tua serie e sblocchi anche questo.",
dettaglio: {
comeSiCalcola:
"Si conta quanti giocatori hanno una serie di almeno 3 allenamenti consecutivi senza saltarne nemmeno uno.",
conta: ["Un giocatore con 3 o più allenamenti di fila in cui era presente o in ritardo."],
nonConta: [
"Chi ha saltato un allenamento: la serie riparte da zero.",
"Un allenamento passato senza risposta vale come assenza e azzera la serie.",
"Le partite: la serie riguarda solo gli allenamenti.",
"Un allenamento saltato per infortunio non la azzera: la serie resta ferma al valore di prima.",
],
periodo: "Si aggiorna di continuo sugli allenamenti passati. Non ha scadenza.",
fonte: "Presenze agli allenamenti. Vedi anche la serie personale nel tuo profilo.",
esempio:
"Il target è 12 perché è il numero minimo per un allenamento 6 contro 6. Con 9 giocatori in serie ne mancano 3.",
},
},
{
id: "o3",
@@ -193,6 +305,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🎉",
impatto: "Una prestazione di gruppo e il primo passo è fatto.",
dettaglio: {
comeSiCalcola:
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con la prima vittoria.",
conta: [
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
],
nonConta: [
"Le sconfitte e le partite non ancora giocate.",
"Amichevoli e allenamenti.",
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
],
periodo: "Tutta la stagione di campionato. Non si azzera.",
fonte:
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
},
},
{
id: "o4",
@@ -203,6 +331,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🔥",
impatto: "Ogni vittoria ci avvicina ai playoff.",
dettaglio: {
comeSiCalcola:
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 5 vittorie.",
conta: [
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
],
nonConta: [
"Le sconfitte e le partite non ancora giocate.",
"Amichevoli e allenamenti.",
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
],
periodo: "Tutta la stagione di campionato. Non si azzera.",
fonte:
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
},
},
{
id: "o5",
@@ -213,6 +357,22 @@ export function obiettiviSquadra(
unita: "vittorie",
emoji: "🏆",
impatto: "L'obiettivo grande: serve tutta la stagione insieme.",
dettaglio: {
comeSiCalcola:
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 10 vittorie.",
conta: [
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
],
nonConta: [
"Le sconfitte e le partite non ancora giocate.",
"Amichevoli e allenamenti.",
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
],
periodo: "Tutta la stagione di campionato. Non si azzera.",
fonte:
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
},
},
{
id: "o6",
@@ -229,6 +389,24 @@ export function obiettiviSquadra(
unita: "eventi",
emoji: "🍕",
impatto: "Il gruppo si costruisce anche fuori dal campo.",
dettaglio: {
comeSiCalcola:
"Si conta quanti eventi di squadra del mese corrente sono già iniziati. Basta uno per completare l'obiettivo.",
conta: [
"Pizzate, cene e uscite fuori dal campo inserite nel calendario come «evento».",
"Solo quando l'ora dell'evento è arrivata. Se manca l'ora vale la mezzanotte.",
],
nonConta: [
"Un evento ancora nel futuro: creato oggi per la settimana prossima non conta ancora.",
"Partite e allenamenti.",
"Eventi di altri mesi.",
],
periodo:
"Si azzera il primo giorno di ogni mese. Il passaggio a completato compare al primo aggiornamento della pagina dopo l'ora dell'evento.",
fonte: "Eventi del calendario.",
esempio:
"Una pizzata fissata il 20 alle 20:30: fino a quell'ora l'obiettivo resta 0/1, dopo diventa 1/1.",
},
},
];
}
+5 -5
View File
@@ -27,13 +27,13 @@ function NotFoundComponent() {
<div className="max-w-md text-center">
<h1 className="font-display-lg text-7xl text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Pagina non trovata</h2>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-[15px] text-muted-foreground">
La pagina che cerchi non esiste o è stata spostata.
</p>
<div className="mt-6">
<Link
to="/"
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
>
Torna alla home
</Link>
@@ -53,7 +53,7 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })
<h1 className="text-xl font-semibold tracking-tight text-foreground">
Questa pagina non si è caricata
</h1>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-[15px] text-muted-foreground">
Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
@@ -63,13 +63,13 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })
router.invalidate();
reset();
}}
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
>
Riprova
</button>
<a
href="/"
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-sm font-semibold text-foreground"
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-[15px] font-semibold text-foreground"
>
Torna alla home
</a>
+24 -95
View File
@@ -1,7 +1,6 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
Bell,
BellOff,
ChevronDown,
@@ -33,11 +32,9 @@ import {
useGiocatoriSquadra,
useImpostaAttivo,
useSalvaDatiSquadra,
useSalvaTesseramento,
useScollegaAccount,
validaDatiSquadra,
type DatiSquadra,
type DatiTesseramento,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
@@ -110,18 +107,11 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
const { righe } = useGiocatoriSquadra();
const salvaSquadra = useSalvaDatiSquadra();
const salvaProfilo = useSalvaProfilo();
const salvaTesseramento = useSalvaTesseramento();
const scollega = useScollegaAccount();
const impostaAttivo = useImpostaAttivo();
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
const [bozza, setBozza] = useState<Profilo | null>(null);
const [tesseramento, setTesseramento] = useState<DatiTesseramento | null>(null);
const tesseramentoCorrente: DatiTesseramento = tesseramento ?? {
numeroTessera: g.numeroTessera,
dataTessera: g.dataTessera,
};
const squadraCorrente: DatiSquadra = datiSquadra ?? {
nome: g.nome,
@@ -131,7 +121,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
email: g.email,
tipo: g.tipo,
};
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
// L'allenatore non ha numero né ruolo in campo (DD-034).
const allenatore = isAllenatore(g);
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
@@ -167,16 +157,6 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
}
}
async function confermaTesseramento() {
try {
await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente });
setTesseramento(null);
toast.success("Tesseramento aggiornato");
} catch (e) {
toast.error(messaggioErrore(e, "Salvataggio non riuscito"));
}
}
async function confermaProfilo() {
try {
await salvaProfilo.mutateAsync(profiloCorrente);
@@ -204,7 +184,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
return (
<div className="mt-3 space-y-3 border-t border-border pt-3">
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati squadra</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
@@ -264,54 +244,14 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
type="button"
onClick={confermaSquadra}
disabled={!datiSquadra || salvaSquadra.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva dati squadra
</button>
{allenatore ? null : (
<>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({
...tesseramentoCorrente,
numeroTessera: e.target.value || null,
})
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
</>
)}
{allenatore ? (
<>
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati personali</h3>
<CampiAllenatore
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
@@ -328,12 +268,12 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
type="button"
onClick={confermaProfilo}
disabled={!bozza || salvaProfilo.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva dati personali
</button>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-[13px]">
<span className="min-w-0 text-muted-foreground">
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
</span>
@@ -404,7 +344,7 @@ function Documento({
onClick={scarica}
disabled={!path || inCorso}
className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase disabled:opacity-60",
statoClasse[stato],
)}
>
@@ -442,12 +382,12 @@ function SchedaGiocatore({
return (
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-[15px] tabular-nums">
{allenatore ? "All" : numero}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold leading-tight">{nome}</p>
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{ruoloVisibile(g)} · profilo {perc}%
</p>
</div>
@@ -486,15 +426,6 @@ function SchedaGiocatore({
stato={certificato}
path={profilo?.certificatoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</div>
)}
@@ -549,7 +480,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
<button
type="button"
onClick={() => setAperto(true)}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
>
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
</button>
@@ -558,7 +489,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
return (
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
<h3 className="font-display text-sm uppercase tracking-wide">
<h3 className="font-display text-[15px] uppercase tracking-wide">
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
</h3>
<Campo label="Tipo">
@@ -632,7 +563,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
setAperto(false);
setDati(datiVuoti);
}}
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-[13px] font-bold uppercase"
>
Annulla
</button>
@@ -640,7 +571,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
type="button"
onClick={() => void conferma()}
disabled={aggiungi.isPending}
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Aggiungi
</button>
@@ -667,7 +598,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
<div className="min-w-0">
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
</p>
</div>
@@ -675,7 +606,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
type="button"
onClick={() => void riattiva()}
disabled={impostaAttivo.isPending}
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-[13px] font-bold text-success-foreground disabled:opacity-50"
>
<UserCheck className="h-3.5 w-3.5" /> Riattiva
</button>
@@ -722,7 +653,7 @@ function Dashboard() {
<>
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
<div className="px-5 pt-4">
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
<Lock className="h-4 w-4 shrink-0" />
Riservata agli amministratori della squadra.
</p>
@@ -743,7 +674,6 @@ function Dashboard() {
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
"valido",
).length;
const tesserati = attivi.filter((g) => g.numeroTessera).length;
const contenutoSquadra = (
<>
@@ -755,12 +685,11 @@ function Dashboard() {
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
className="premi mt-3 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 tesseramento
</button>
@@ -769,7 +698,7 @@ function Dashboard() {
);
const contenutoProfili = isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
Caricamento…
</p>
) : (
@@ -786,7 +715,7 @@ function Dashboard() {
/>
))}
{allenatori.length > 0 ? (
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
) : null}
{allenatori.map((g, i) => (
<SchedaGiocatore
@@ -819,7 +748,7 @@ function Dashboard() {
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
className="premi mt-3 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"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
@@ -835,7 +764,7 @@ function Dashboard() {
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
@@ -855,7 +784,7 @@ function Dashboard() {
</div>
</>
) : (
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
Caricamento…
</p>
);
@@ -909,7 +838,7 @@ function Dashboard() {
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="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 disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -921,7 +850,7 @@ function Dashboard() {
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
>
Annulla
</button>
+7 -5
View File
@@ -43,7 +43,9 @@ function AllenamentoDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
<p className="mt-8 text-center text-[15px] text-muted-foreground">
Allenamento non trovato
</p>
</div>
);
}
@@ -59,14 +61,14 @@ function AllenamentoDetail() {
<Dumbbell className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Allenamento di squadra
</p>
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
@@ -76,12 +78,12 @@ function AllenamentoDetail() {
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
</div>
+5 -5
View File
@@ -107,34 +107,34 @@ function Benvenuto() {
{!pronta ? null : !utenteId ? (
<>
<p className="mt-2 text-center text-sm text-muted-foreground">
<p className="mt-2 text-center text-[15px] text-muted-foreground">
Accedi con il tuo account Google per collegare il profilo giocatore.
</p>
<button
type="button"
onClick={accedi}
disabled={inCorso}
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
>
<LogIn className="h-4 w-4" /> Accedi con Google
</button>
</>
) : erroreCollegamento ? (
<>
<p className="mt-2 text-center text-sm text-muted-foreground">
<p className="mt-2 text-center text-[15px] text-muted-foreground">
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
account.
</p>
<button
type="button"
onClick={() => void esciERiprova()}
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-[15px] font-bold uppercase shadow-card"
>
<LogOut className="h-4 w-4" /> Esci
</button>
</>
) : inAttesaCollegamento ? (
<p className="mt-2 text-center text-sm text-muted-foreground">
<p className="mt-2 text-center text-[15px] text-muted-foreground">
Ti stiamo collegando al tuo profilo...
</p>
) : null}
+8 -8
View File
@@ -119,7 +119,7 @@ function Calendario() {
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
@@ -192,7 +192,7 @@ function Calendario() {
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
style={sfondo ? { backgroundImage: sfondo } : undefined}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold",
tipo === "partita" && "bg-accent text-accent-foreground",
tipo === "allenamento" && "bg-training text-training-foreground",
tipo === "evento" && "bg-warning text-warning-foreground",
@@ -224,10 +224,10 @@ function Calendario() {
</motion.div>
</AnimatePresence>
</div>
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
Scorri a destra o sinistra per cambiare mese.
</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
</span>
@@ -248,7 +248,7 @@ function Calendario() {
<div className="px-5 pt-4">
<Link
to="/eventi"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[14px] font-bold uppercase text-accent-foreground shadow-pop"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
</Link>
@@ -263,7 +263,7 @@ function Calendario() {
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
@@ -275,7 +275,7 @@ function Calendario() {
{compleanniMese.length > 0 ? (
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
</p>
)}
@@ -300,7 +300,7 @@ function Calendario() {
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun evento in questa data
</p>
)}
+15 -15
View File
@@ -82,7 +82,7 @@ export const Route = createFileRoute("/classifica")({
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
return (
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-[13px] font-bold uppercase text-muted-foreground">
<span>#</span>
<span>Squadra</span>
<span className="text-center">G</span>
@@ -90,7 +90,7 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
<span className="text-center">Pt</span>
</div>
{righe.length === 0 ? (
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{vuoto}</p>
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">{vuoto}</p>
) : (
righe.map((r) => {
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
@@ -98,14 +98,14 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
<div
key={r.pos}
className={cn(
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-[15px] last:border-0",
noi && "bg-accent/10",
)}
>
<span className={cn("font-display text-base", noi && "text-accent")}>{r.pos}</span>
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>{r.squadra}</span>
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
<span className="text-center text-xs tabular-nums text-muted-foreground">
<span className="text-center text-[13px] text-muted-foreground">{r.giocate}</span>
<span className="text-center text-[13px] tabular-nums text-muted-foreground">
{r.setFatti}:{r.setSubiti}
</span>
<span className="text-center font-bold tabular-nums">{r.punti}</span>
@@ -171,7 +171,7 @@ function Classifica() {
<>
<div
className={cn(
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-[13px]",
csi?.formatoSospetto
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
@@ -190,9 +190,9 @@ function Classifica() {
</div>
{classificaCoppa.length > 0 ? (
<>
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
<h3 className="mb-2 px-1 text-[15px] font-bold text-foreground">Coppa</h3>
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
<h3 className="mb-2 mt-4 px-1 text-[15px] font-bold text-foreground">Girone</h3>
</>
) : null}
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
@@ -204,14 +204,14 @@ function Classifica() {
const contenutoStorico = useMemo(
() =>
tuttiMatch.length === 0 ? (
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-[13px] text-muted-foreground shadow-card">
Nessuna partita disponibile.
</p>
) : (
<div className="space-y-5">
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
<section key={stagione} className="space-y-3">
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
<h3 className="px-1 text-[15px] font-bold text-foreground">Stagione {stagione}</h3>
{match.map((m) => {
const vinta = m.setNostri > m.setLoro;
const eventoId = eventoIdPerData.get(m.data);
@@ -237,7 +237,7 @@ function Classifica() {
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{casa.nome}</p>
<p className="truncate text-[15px] font-bold">{casa.nome}</p>
</div>
<div className="flex min-w-0 items-center gap-2">
<LogoPartita
@@ -245,9 +245,9 @@ function Classifica() {
logoAvversario={m.logoAvversario}
avversario={m.avversario}
/>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
<p className="truncate text-[15px] font-bold">{trasferta.nome}</p>
</div>
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{formatData(m.data)} · MVP {m.mvp || "da votare"}
{m.scout ? " · scoutata" : ""}
</p>
@@ -278,7 +278,7 @@ function Classifica() {
<span
key={i}
className={cn(
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
"rounded-lg px-2 py-1 text-[13px] font-semibold tabular-nums",
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
)}
>
@@ -287,7 +287,7 @@ function Classifica() {
);
})}
{eventoId && !m.mvp ? (
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
<span className="ml-auto inline-flex items-center gap-0.5 text-[13px] font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
+26 -24
View File
@@ -91,7 +91,7 @@ function GestioneEventi() {
<>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
<div className="px-5">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
Solo i referenti della squadra possono creare o modificare gli eventi.
</p>
</div>
@@ -207,7 +207,7 @@ function GestioneEventi() {
</button>
</div>
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
@@ -248,7 +248,7 @@ function GestioneEventi() {
type="button"
onClick={() => apriGiorno(giorno)}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold transition-transform active:scale-90",
haEventi
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
@@ -265,11 +265,11 @@ function GestioneEventi() {
</AnimatePresence>
</div>
{isPending ? (
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
<p aria-busy="true" className="mt-2 text-[13px] text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="mt-3 space-y-2 text-center text-sm">
<div className="mt-3 space-y-2 text-center text-[15px]">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
@@ -277,13 +277,13 @@ function GestioneEventi() {
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
className="rounded-2xl bg-secondary px-4 py-2 text-[13px] font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
modificare o eliminare.
</p>
@@ -303,7 +303,7 @@ function GestioneEventi() {
type="button"
onClick={() => aggiorna(daCategoria(t.id))}
className={cn(
"rounded-full py-2 text-xs font-bold uppercase transition-colors",
"rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
categoriaEvento(bozza) === t.id
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
@@ -337,15 +337,17 @@ function GestioneEventi() {
</Campo>
) : (
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Giorno
</span>
<div className="mt-1 flex h-10 items-center justify-between gap-1">
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
<span className="truncate text-[15px] font-semibold">
{formatData(bozza.data)}
</span>
<button
type="button"
onClick={() => setCambiaGiorno(true)}
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
className="shrink-0 rounded-full px-2 py-1 text-[13px] font-bold uppercase text-accent active:scale-95"
>
Cambia
</button>
@@ -394,7 +396,7 @@ function GestioneEventi() {
type="button"
onClick={() => aggiorna({ casa: o.casa })}
className={cn(
"flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors",
"flex-1 rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
bozza.casa === o.casa
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
@@ -433,7 +435,7 @@ function GestioneEventi() {
})
}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
scelto
? "bg-accent text-accent-foreground"
: "bg-secondary text-muted-foreground",
@@ -450,7 +452,7 @@ function GestioneEventi() {
<button
type="button"
onClick={() => setBozza(null)}
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
className="flex-1 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
>
Annulla
</button>
@@ -458,7 +460,7 @@ function GestioneEventi() {
type="button"
onClick={chiediConferma}
disabled={salva.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
</button>
@@ -489,8 +491,8 @@ function GestioneEventi() {
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
<p className="truncate text-[15px] font-bold leading-tight">{e.titolo}</p>
<p className="text-[13px] text-muted-foreground">
{e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
@@ -513,7 +515,7 @@ function GestioneEventi() {
</div>
))
) : (
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
@@ -521,7 +523,7 @@ function GestioneEventi() {
<button
type="button"
onClick={nuovoNelGiorno}
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
className="premi mt-1 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"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
</button>
@@ -544,14 +546,14 @@ function GestioneEventi() {
type="button"
onClick={conferma}
disabled={salva.isPending}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="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 disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
>
Annulla
</button>
@@ -573,14 +575,14 @@ function GestioneEventi() {
type="button"
onClick={() => daEliminare && rimuovi(daEliminare.id)}
disabled={elimina.isPending}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50"
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-[15px] font-bold uppercase text-destructive-foreground disabled:opacity-50"
>
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
>
Annulla
</button>
@@ -606,7 +608,7 @@ function Interruttore({
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-bold uppercase transition-colors",
"rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors",
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
)}
>
+5 -5
View File
@@ -42,7 +42,7 @@ function EventoDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
<p className="mt-8 text-center text-[15px] text-muted-foreground">Evento non trovato</p>
</div>
);
}
@@ -58,14 +58,14 @@ function EventoDetail() {
<PartyPopper className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Evento extra-campo
</p>
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
@@ -75,12 +75,12 @@ function EventoDetail() {
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
</div>
+42 -27
View File
@@ -9,6 +9,7 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { Reveal } from "@/components/motion/Reveal";
import { Barra } from "@/components/motion/Barra";
import { Numero } from "@/components/motion/Numero";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze";
@@ -85,7 +86,7 @@ function Index() {
className="h-14 w-14 rounded-full shadow-pop ring-2 ring-primary-foreground/25"
/>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
<p className="text-[13px] font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
Ciao {nome.split(" ")[0]}
</p>
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
@@ -99,13 +100,13 @@ function Index() {
>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
<p className="text-[13px] uppercase text-primary-foreground/80">In classifica</p>
</div>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">
{noi ? `${noi.vinte}-${noi.perse}` : "—"}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
<p className="text-[13px] uppercase text-primary-foreground/80">Bilancio W-L</p>
</div>
{giocatore ? (
<div className="rounded-2xl bg-primary-foreground/10 p-3">
@@ -113,7 +114,7 @@ function Index() {
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
<p className="text-[13px] uppercase text-primary-foreground/80">Streak</p>
</div>
) : null}
</div>
@@ -131,7 +132,7 @@ function Index() {
azione={
<Link
to="/calendario"
className="inline-flex items-center text-[14px] font-semibold text-accent"
className="inline-flex items-center text-[15px] font-semibold text-accent"
>
Calendario <ChevronRight className="h-4 w-4" />
</Link>
@@ -140,7 +141,7 @@ function Index() {
{prossimo ? (
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
Nessun impegno in programma.
</p>
)}
@@ -155,7 +156,7 @@ function Index() {
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
@@ -170,7 +171,7 @@ function Index() {
<Link
to="/classifica"
search={{ tab: "storico" }}
className="inline-flex items-center text-[14px] font-semibold text-accent"
className="inline-flex items-center text-[15px] font-semibold text-accent"
>
Storico <ChevronRight className="h-4 w-4" />
</Link>
@@ -182,8 +183,10 @@ function Index() {
<>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
<p className="text-xs text-muted-foreground">
<p className="truncate text-[15px] font-bold">
CRAP Volley vs {ultima.avversario}
</p>
<p className="text-[13px] text-muted-foreground">
{ultima.casa ? "In casa" : "Trasferta"}
{ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}
</p>
@@ -196,7 +199,7 @@ function Index() {
{ultima.parziali.map((p, i) => (
<span
key={i}
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
@@ -217,7 +220,7 @@ function Index() {
);
})()
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
Nessun risultato disponibile.
</p>
)}
@@ -230,27 +233,39 @@ function Index() {
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[14px] font-semibold text-accent"
className="inline-flex items-center text-[15px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<Card>
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
</Card>
<ObiettivoDrawer obiettivo={obiettivo}>
<button
type="button"
aria-label={`Dettaglio obiettivo: ${obiettivo.titolo}`}
className="block w-full text-left"
>
<Card>
<div className="flex items-center gap-2 text-[15px] font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span>{" "}
{obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-[13px] text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-[13px] font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-[13px] text-muted-foreground">{obiettivo.impatto}</p>
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
Come funziona <ChevronRight className="h-3.5 w-3.5" />
</p>
</Card>
</button>
</ObiettivoDrawer>
) : null}
</Section>
)}
+3 -3
View File
@@ -40,7 +40,7 @@ function PartitaCsiDetail() {
if (!partita) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
</div>
);
}
@@ -69,7 +69,7 @@ function PartitaCsiDetail() {
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{partita.casa ? "In casa" : "Fuori casa"}
</p>
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</p>
@@ -93,7 +93,7 @@ function PartitaCsiDetail() {
{partita.parziali.map(([noi, loro], i) => (
<span
key={i}
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
>
{noi}-{loro}
</span>
+15 -13
View File
@@ -60,7 +60,7 @@ function PartitaDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
</div>
);
}
@@ -118,7 +118,7 @@ function PartitaDetail() {
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
) : null}
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{casa ? "In casa" : "Fuori casa"}
{evento.campionato ? " · Campionato" : " · Amichevole"}
</p>
@@ -126,7 +126,7 @@ function PartitaDetail() {
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
@@ -136,12 +136,12 @@ function PartitaDetail() {
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocati.length}
</div>
@@ -163,7 +163,7 @@ function PartitaDetail() {
<Section titolo="Risultato">
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">CRAP Volley</span>
<span className="text-[15px] font-bold">CRAP Volley</span>
<span
className={cn(
"rounded-full px-3 py-1 text-lg font-display font-bold",
@@ -174,7 +174,7 @@ function PartitaDetail() {
>
{match.setNostri} - {match.setLoro}
</span>
<span className="text-right text-sm font-bold">{avversario}</span>
<span className="text-right text-[15px] font-bold">{avversario}</span>
</div>
<div className="mt-4 space-y-2">
{match.parziali.map(([noi, loro], i) => (
@@ -183,7 +183,9 @@ function PartitaDetail() {
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
>
<span className="font-display text-lg">{noi}</span>
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
<span className="text-[13px] font-semibold text-muted-foreground">
Set {i + 1}
</span>
<span className="font-display text-lg">{loro}</span>
</div>
))}
@@ -213,7 +215,7 @@ function PartitaDetail() {
)
) : (
<Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
risultato.
</p>
@@ -248,13 +250,13 @@ function PartitaDetail() {
].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-xs font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
{t.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<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">
@@ -264,7 +266,7 @@ function PartitaDetail() {
return (
<div
key={gid}
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
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}
@@ -285,7 +287,7 @@ function PartitaDetail() {
csvScoutMatch(scout, rosa),
)
}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
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>
+10 -10
View File
@@ -220,7 +220,7 @@ function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
<button
type="button"
onClick={() => inputRef.current?.click()}
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-[13px] font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
@@ -312,11 +312,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-xs text-muted-foreground">
<span className="block text-[13px] text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
@@ -339,11 +339,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
type="button"
onClick={cambiaEmail}
disabled={!email.pronta || email.imposta.isPending}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Email</span>
<span className="block text-xs text-muted-foreground">
<span className="block text-[13px] text-muted-foreground">
{email.attive
? "Le notifiche arrivano anche via email"
: "Nessuna email, solo notifiche in app"}
@@ -363,7 +363,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
{admin ? (
<Link
to="/admin"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Dashboard amministratore</span>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
@@ -373,7 +373,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Segnala un bug</span>
<Bug className="h-4 w-4 text-muted-foreground" />
@@ -382,17 +382,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<p className="text-center text-[13px] text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-[15px] font-bold text-destructive shadow-card"
>
<LogOut className="h-4 w-4" />
Esci
+23 -23
View File
@@ -72,7 +72,7 @@ function Blocco({
{icona}
</div>
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
<p className="mt-2 text-sm text-muted-foreground">{testo}</p>
<p className="mt-2 text-[15px] text-muted-foreground">{testo}</p>
{children}
</div>
</div>
@@ -138,7 +138,7 @@ function Scout() {
<button
type="button"
onClick={() => sessione.refetch()}
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
>
Aggiorna
</button>
@@ -156,7 +156,7 @@ function Scout() {
if (ok) setControllo(true);
else toast.error("Un altro compagno ha appena preso lo scout");
}}
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
</button>
@@ -313,7 +313,7 @@ function ScoutBoard({
<>
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-xs font-bold uppercase tracking-wide">
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-[13px] font-bold uppercase tracking-wide">
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
</span>
<div className="flex items-center gap-2">
@@ -321,14 +321,14 @@ function ScoutBoard({
type="button"
onClick={annulla}
disabled={azioniSet.length === 0}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
>
<Undo2 className="h-3.5 w-3.5" /> Annulla
</button>
<button
type="button"
onClick={onFine}
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold"
>
<LogOut className="h-3.5 w-3.5" /> Rilascia
</button>
@@ -337,14 +337,14 @@ function ScoutBoard({
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
<div className="min-w-0">
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="truncate text-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-xs uppercase text-primary-foreground/60">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
{avversario || "Avversario"}
</p>
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
@@ -356,7 +356,7 @@ function ScoutBoard({
{setChiusi.map((p, i) => (
<span
key={i}
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs font-semibold tabular-nums"
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[13px] font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
@@ -366,7 +366,7 @@ function ScoutBoard({
</header>
<section className="px-5 pt-4">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
1. Tocca il giocatore
</p>
<div className="grid grid-cols-4 gap-2">
@@ -384,10 +384,10 @@ function ScoutBoard({
)}
>
<p className="font-display text-2xl leading-none">{g.numero}</p>
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
<p className="mt-1 text-[13px] font-bold leading-tight">{g.nome}</p>
<p
className={cn(
"text-xs tabular-nums",
"text-[13px] tabular-nums",
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
)}
>
@@ -400,7 +400,7 @@ function ScoutBoard({
</section>
<section className="px-5 pt-4">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
2. Tocca l'azione
</p>
<div className="grid grid-cols-2 gap-2">
@@ -426,7 +426,7 @@ function ScoutBoard({
type="button"
onClick={() => registra(tipo)}
className={cn(
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
"rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
azioniMeta[tipo].className,
)}
>
@@ -438,11 +438,11 @@ function ScoutBoard({
<section className="px-5 pt-4">
<div className="rounded-3xl bg-card p-3 shadow-card">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni
</p>
{ultime.length === 0 ? (
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p>
) : (
<ul className="mt-2 space-y-1.5">
{ultime.map((a, i) => {
@@ -451,14 +451,14 @@ function ScoutBoard({
<li
key={a.id}
className={cn(
"flex items-center justify-between gap-2 px-1 text-xs",
"flex items-center justify-between gap-2 px-1 text-[13px]",
i === 0 && "anim-riga",
)}
>
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-bold uppercase",
"shrink-0 rounded-full px-2 py-0.5 text-[13px] font-bold uppercase",
azioniMeta[a.tipo].className,
)}
>
@@ -474,14 +474,14 @@ function ScoutBoard({
<section className="px-5 pt-4">
<Card>
<label className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
<label className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Avversario
</label>
<input
value={avversario}
maxLength={40}
onChange={(e) => setAvversario(e.target.value)}
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]"
/>
<div className="mt-3 flex rounded-full bg-secondary p-1">
{[true, false].map((v) => (
@@ -490,7 +490,7 @@ function ScoutBoard({
type="button"
onClick={() => setCasa(v)}
className={cn(
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
"flex-1 rounded-full py-1.5 text-[13px] font-bold uppercase",
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
)}
>
@@ -505,7 +505,7 @@ function ScoutBoard({
<button
type="button"
onClick={chiudiSet}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
>
<CheckCircle2 className="h-4 w-4" /> Chiudi set
</button>
@@ -513,7 +513,7 @@ function ScoutBoard({
type="button"
onClick={finePartita}
disabled={salvaMatch.isPending}
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
<Save className="h-4 w-4" /> Fine partita
</button>
+73 -56
View File
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData, inizialiDa } from "@/lib/crapp-data";
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import {
useRosa,
@@ -50,7 +51,7 @@ import {
function RuoloBadge({ ruolo }: { ruolo: string }) {
return (
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-accent-foreground">
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide text-accent-foreground">
{ruolo}
</span>
);
@@ -146,7 +147,7 @@ function Squadra() {
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
<span className="block truncate text-[15px] font-bold leading-tight">
{nomeCompleto(a)}
</span>
<span className="mt-1 flex items-center gap-2">
@@ -163,7 +164,7 @@ function Squadra() {
{isOpen && a.nascita ? (
<div className="border-t border-border px-4 pb-4 pt-3">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
</p>
</div>
@@ -191,10 +192,12 @@ function Squadra() {
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="block truncate text-[15px] font-bold leading-tight">
{g.nome}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-xs text-muted-foreground">
<span className="text-[13px] text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
@@ -229,7 +232,7 @@ function Squadra() {
<div className="border-t border-border px-4 pb-4 pt-3">
{g.nascita ? (
<div className="flex flex-wrap items-center gap-2">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
@@ -243,13 +246,11 @@ function Squadra() {
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
...(sonoAllenatore
? []
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
...(sonoAllenatore ? [] : [{ l: "Cacche 💩", v: g.cacchePartita || "—" }]),
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
@@ -258,11 +259,11 @@ function Squadra() {
{sonoAllenatore ? null : (
<>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-[13px] text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
@@ -274,17 +275,20 @@ function Squadra() {
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">
<p className="mt-1 text-[13px] font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
className={cn(
"mt-1.5 text-[13px] font-bold uppercase",
meta.text,
)}
>
{meta.label}
</p>
@@ -326,8 +330,8 @@ function Squadra() {
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
>
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1 truncate text-right text-[14px] font-bold">
<span className="shrink-0 text-[15px] text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -345,7 +349,7 @@ function Squadra() {
type="button"
onClick={() => setCriterio(c.id)}
className={cn(
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-[15px] font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
@@ -368,13 +372,13 @@ function Squadra() {
{rank[i]}
</span>
<div className="min-w-0">
<p className="truncate text-sm font-bold">
<p className="truncate text-[15px] font-bold">
{g.nome}
{rank[i] === 1 ? (
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
) : null}
</p>
<p className="truncate text-xs text-muted-foreground">
<p className="truncate text-[13px] text-muted-foreground">
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
</p>
</div>
@@ -414,14 +418,14 @@ function Squadra() {
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
Progresso collettivo
</p>
<p className="font-display text-4xl leading-none">
<Numero valore={mediaObiettivi} suffisso="%" />
</p>
</div>
<p className="text-xs text-primary-foreground/70">
<p className="text-[13px] text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
@@ -432,38 +436,47 @@ function Squadra() {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal
key={o.id}
indice={i}
className={cn(
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
)}
>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
</div>
<span
<Reveal key={o.id} indice={i}>
<ObiettivoDrawer obiettivo={o}>
<button
type="button"
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
className={cn(
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
"premi block w-full rounded-3xl bg-card p-4 text-left shadow-card ring-1",
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
<div className="flex items-start gap-2">
<span className="text-lg leading-none">{o.emoji}</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] font-bold leading-tight">{o.titolo}</p>
<p className="text-[13px] text-muted-foreground">{o.descrizione}</p>
</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide",
fatto
? "bg-success text-success-foreground"
: "bg-secondary text-muted-foreground",
)}
>
{fatto ? "Completato" : `${pct}%`}
</span>
</div>
<Barra percentuale={pct} trackClassName="mt-3" />
<p className="mt-2 text-[13px] text-muted-foreground">
{o.valore}/{o.target} {o.unita} · {pct}%
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
</p>
<p className="mt-1 text-[13px] font-semibold text-accent">
{microcopyObiettivo(o)}
</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">{o.impatto}</p>
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
Come funziona <ChevronRight className="h-3.5 w-3.5" />
</p>
</button>
</ObiettivoDrawer>
</Reveal>
);
})}
@@ -483,8 +496,10 @@ function Squadra() {
<div className="rounded-3xl bg-card p-3 shadow-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-accent" />
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
<p className="text-[15px] font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-[13px] text-muted-foreground">
{descrizioneSoglie(b)}
</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
@@ -500,9 +515,11 @@ function Squadra() {
key={grado}
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
>
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
<p className={cn("text-[13px] font-bold uppercase", meta.text)}>
{meta.label}
</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-xs text-muted-foreground">
<p className="text-[13px] text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
+1 -1
View File
@@ -26,7 +26,7 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
| Cartella | Cosa verifica | Serve rete? |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
+60
View File
@@ -468,6 +468,66 @@ if (!locale) {
},
);
await prova(
"o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no",
async () => {
// Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa:
// g1 presente, g2 assente (presenza persa), g3 in ritardo. g4 non è convocato ma risponde
// «assente»: non deve pesare. La cena del mese ha risposte «assente» e non deve contare.
const allenamentoId = `${PREFISSO}-o1-convocati-allenamento`;
const cenaId = `${PREFISSO}-o1-convocati-cena`;
const [g1, g2, g3, g4] = giocatori;
for (const [id, tipo, data, convocati] of [
[allenamentoId, "allenamento", "2099-03-12", [g1!.id, g2!.id, g3!.id]],
[cenaId, "evento", "2099-03-14", [g1!.id, g2!.id, g3!.id]],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id,
tipo,
titolo: `Test o1 convocati (${tipo})`,
data,
convocati,
}),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
const righe = [
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "assente" },
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "ritardo" },
{ evento_id: allenamentoId, giocatore_id: g4!.id, stato: "assente" },
{ evento_id: cenaId, giocatore_id: g1!.id, stato: "assente" },
{ evento_id: cenaId, giocatore_id: g2!.id, stato: "assente" },
];
const inserite = await rest("risposte_presenze", {
method: "POST",
body: JSON.stringify(righe),
});
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
const ids = [allenamentoId, cenaId];
const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id));
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(cenaId)),
};
const o1 = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
OGGI,
).find((o) => o.id === "o1")!;
assert.equal(
o1.valore,
67,
"2 presenti (presente e ritardo) su 3 convocati; g4 non convocato e la cena non contano",
);
},
);
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
const matchId = `${PREFISSO}-o12-m1`;
const [g1, g2, g3] = giocatori;
+84
View File
@@ -0,0 +1,84 @@
/** Check del componente degli avvisi in Home: `bun test/unit/avviso.test.ts`. */
import assert from "node:assert/strict";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { CalendarClock, FileX } from "lucide-react";
import { CorpoAvviso, type TonoAvviso } from "@/components/crapp/Avviso";
const html = (p: Parameters<typeof CorpoAvviso>[0]) =>
renderToStaticMarkup(createElement(CorpoAvviso, p));
// --- ogni tono ha il suo colore, e l'icona è decorativa (il titolo dice già tutto) ---
const classi: Record<TonoAvviso, string> = {
critico: "bg-destructive",
attenzione: "bg-warning",
scaduto: "bg-primary",
azione: "bg-accent-grad",
};
for (const [tono, classe] of Object.entries(classi) as [TonoAvviso, string][]) {
const h = html({ tono, icona: FileX, titolo: "Titolo" });
assert.ok(h.includes(classe), `${tono}: classe colore ${classe}`);
assert.ok(h.includes("Titolo"), `${tono}: titolo`);
assert.ok(h.includes('aria-hidden="true"'), `${tono}: icona nascosta ai lettori di schermo`);
}
// --- testi: un paragrafo per frase ---------------------------------------------------
const conTesti = html({
tono: "attenzione",
icona: CalendarClock,
titolo: "Certificato in scadenza",
testi: ["Prima frase.", "Seconda frase."],
});
assert.equal((conTesti.match(/<p /g) ?? []).length, 3, "titolo + due paragrafi");
assert.ok(!conTesti.includes("<ul"), "senza elenco niente lista");
// --- elenco: righe chi/cosa, conteggio solo con più di una riga ----------------------
const una = html({
tono: "critico",
icona: FileX,
titolo: "Certificati mancanti",
elenco: [{ chi: "Mario Rossi", cosa: "non caricato" }],
});
assert.ok(una.includes("Mario Rossi") && una.includes("non caricato"), "riga con chi e cosa");
assert.ok(!una.includes("persone"), "una sola riga: nessun conteggio");
const tre = html({
tono: "critico",
icona: FileX,
titolo: "Certificati mancanti",
elenco: [
{ chi: "Mario Rossi", cosa: "non caricato" },
{ chi: "Luca Bianchi", cosa: "non caricato" },
{ chi: "Anna Verdi", cosa: "non caricato" },
],
});
assert.equal((tre.match(/<li /g) ?? []).length, 3, "una riga per persona");
assert.ok(tre.includes('aria-label="3 persone"'), "conteggio accessibile");
// --- freccia solo se la card porta altrove -------------------------------------------
const base = { tono: "scaduto", icona: FileX, titolo: "t" } as const;
assert.ok(!html(base).includes("lucide-chevron-right"), "senza link niente freccia");
assert.ok(
html({ ...base, cliccabile: true }).includes("lucide-chevron-right"),
"con link la freccia",
);
// --- etichetta e riquadri (turno palloni) --------------------------------------------
const palloni = html({
tono: "azione",
icona: FileX,
titolo: "Turno palloni",
etichetta: "Oggi",
testi: ["Porta i palloni."],
riquadri: true,
});
assert.ok(palloni.includes("Oggi"), "etichetta mostrata");
assert.ok(palloni.includes("bg-black/15"), "il messaggio è in un riquadro");
assert.ok(palloni.includes("bg-black/25"), "etichetta in pillola");
assert.ok(!html({ ...base }).includes("bg-black/25"), "senza etichetta niente pillola");
assert.ok(
!html({ ...base, testi: ["x"] }).includes("bg-black/15"),
"senza riquadri testo semplice",
);
console.log("avviso: ok");
+1
View File
@@ -69,6 +69,7 @@ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
unita: "%",
emoji: "🎯",
impatto: "",
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
});
assert.deepEqual(
ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")),
+106
View File
@@ -0,0 +1,106 @@
/** Check della card di dettaglio degli obiettivi: `bun test/unit/obiettivi-dettaglio.test.ts`. */
import assert from "node:assert/strict";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { SchedaObiettivo } from "@/components/crapp/ObiettivoDrawer";
import { Drawer } from "@/components/ui/drawer";
import { giocatori } from "@/lib/crapp-data";
import { contestoVuoto, obiettiviSquadra, type ObiettivoSquadra } from "@/lib/obiettivi";
const OGGI = new Date("2026-08-15T10:00:00Z");
const tutti = obiettiviSquadra(giocatori, contestoVuoto, OGGI);
/** Rende la scheda dentro un Drawer aperto, come fa l'app (titolo e descrizione ne hanno bisogno). */
const scheda = (o: ObiettivoSquadra) =>
renderToStaticMarkup(
createElement(Drawer, { open: true }, createElement(SchedaObiettivo, { o })),
);
// --- ogni obiettivo ha tutti i testi, nessun campo vuoto ----------------------------
const ids = tutti.map((o) => o.id).sort();
assert.deepEqual(
ids,
["o1", "o11", "o12", "o13", "o2", "o3", "o4", "o5", "o6", "o7"],
"gli obiettivi sono quelli attesi: se ne aggiungi uno, scrivi il suo dettaglio",
);
for (const o of tutti) {
const d = o.dettaglio;
for (const [nome, testo] of Object.entries({
comeSiCalcola: d.comeSiCalcola,
periodo: d.periodo,
fonte: d.fonte,
esempio: d.esempio,
})) {
assert.ok(testo.trim().length > 20, `${o.id}: «${nome}» troppo corto o vuoto`);
}
for (const voce of [...d.conta, ...d.nonConta, ...(d.perse ?? [])]) {
assert.ok(voce.trim().length > 10, `${o.id}: voce di elenco vuota o troppo corta`);
}
assert.equal(new Set(d.conta).size, d.conta.length, `${o.id}: voci «conta» duplicate`);
assert.equal(new Set(d.nonConta).size, d.nonConta.length, `${o.id}: voci «non conta» duplicate`);
}
// --- «presenza persa» solo sulle due percentuali di presenze ------------------------
for (const o of tutti) {
const haPerse = Boolean(o.dettaglio.perse && o.dettaglio.perse.length > 0);
assert.equal(haPerse, o.id === "o1" || o.id === "o7", `${o.id}: presenza persa solo su o1 e o7`);
}
// --- le due presenze spiegano gli stessi punti chiave -------------------------------
for (const id of ["o1", "o7"]) {
const d = tutti.find((o) => o.id === id)!.dettaglio;
const tutto = [d.comeSiCalcola, ...d.conta, ...(d.perse ?? []), ...d.nonConta].join(" ");
for (const parola of ["convocat", "presente", "in ritardo", "assente", "cene"]) {
assert.ok(tutto.toLowerCase().includes(parola), `${id}: la spiegazione non cita «${parola}»`);
}
assert.ok(
d.nonConta.some((v) => /non è convocato/i.test(v)),
`${id}: dice che il non convocato non pesa`,
);
}
const mese = tutti.find((o) => o.id === "o1")!.dettaglio;
const totale = tutti.find((o) => o.id === "o7")!.dettaglio;
assert.ok(/mese/i.test(mese.periodo) && /azzera/i.test(mese.periodo), "o1: si azzera ogni mese");
assert.ok(/stagione/i.test(totale.periodo) && /mai/i.test(totale.periodo), "o7: non si azzera mai");
// --- la card renderizzata mostra tutte le sezioni ------------------------------------
const o1 = tutti.find((o) => o.id === "o1")!;
const html1 = scheda(o1);
for (const titolo of [
"Stato attuale",
"Come si calcola",
"Cosa conta",
"Presenza persa",
"Non entra nel calcolo",
"Quando vale",
"Da dove arrivano i dati",
"Un esempio",
]) {
assert.ok(html1.includes(titolo), `la card di o1 non mostra «${titolo}»`);
}
assert.ok(html1.includes(o1.titolo.replace(/&/g, "&amp;")), "titolo");
assert.ok(html1.includes(o1.dettaglio.comeSiCalcola), "testo «come si calcola»");
assert.ok(html1.includes(o1.impatto), "frase di impatto");
assert.ok(html1.includes("entro il"), "o1 ha una scadenza mensile");
assert.ok(
!html1.includes("Cosa non conta"),
"con «presenza persa» la sezione si chiama diversamente",
);
// Un obiettivo senza «presenza persa» ha «Cosa non conta» e nessuna sezione in più.
const o12 = tutti.find((o) => o.id === "o12")!;
const html12 = scheda(o12);
assert.ok(html12.includes("Cosa non conta"), "o12: «Cosa non conta»");
assert.ok(!html12.includes("Presenza persa"), "o12: nessuna «presenza persa»");
assert.ok(!html12.includes("Non entra nel calcolo"), "o12: nessun «non entra nel calcolo»");
assert.ok(!html12.includes("entro il"), "o12 non ha scadenza");
// --- stato: in corso / completato, e i numeri mostrati --------------------------------
const finto = (valore: number): ObiettivoSquadra => ({ ...o12, valore, target: 10 });
assert.ok(scheda(finto(4)).includes("In corso"), "sotto il target: in corso");
assert.ok(scheda(finto(4)).includes("40%"), "percentuale mostrata");
assert.ok(scheda(finto(10)).includes("Completato"), "al target: completato");
assert.ok(!scheda(finto(10)).includes("In corso"), "al target non è più in corso");
console.log("obiettivi-dettaglio: ok");
+177
View File
@@ -100,6 +100,169 @@ const soloCompleanni: ContestoObiettivi = {
};
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0);
// --- o1: chi non è convocato non occupa un posto, la sua assenza non pesa -------
{
const [g1, g2] = giocatori;
const mese: ContestoObiettivi = {
...contestoVuoto,
eventi: [{ ...evento("m1", "2026-08-10", "allenamento"), convocati: [g1!.id, g2!.id] }],
presenze: { m1: { [g1!.id]: "presente", [g2!.id]: "assente" } },
};
assert.equal(
trova(obiettiviSquadra(giocatori, mese, OGGI_AGOSTO), "o1").valore,
50,
"su 2 convocati 1 presente e 1 assente: 50%, il resto della rosa non conta",
);
assert.equal(
trova(
obiettiviSquadra(
giocatori,
{ ...mese, eventi: [evento("m1", "2026-08-10", "allenamento")] },
OGGI_AGOSTO,
),
"o1",
).valore,
Math.round((1 / giocatori.length) * 100),
"senza convocati i posti sono tutta la rosa",
);
}
// --- presenze (o1 mese, o7 totale): casi limite del calcolo ------------------------
{
const [g1, g2, g3, g4] = giocatori;
const P = (...x: [string, "presente" | "ritardo" | "assente" | "forse" | "infortunato"][]) =>
Object.fromEntries(x);
const o1v = (c: ContestoObiettivi, oggi = OGGI_AGOSTO) =>
trova(obiettiviSquadra(giocatori, c, oggi), "o1").valore;
const o7v = (c: ContestoObiettivi) => trova(obiettiviSquadra(giocatori, c), "o7").valore;
const conv = (id: string, data: string, tipo: Evento["tipo"], convocati: string[]) => ({
...evento(id, data, tipo),
convocati,
});
// Solo allenamenti e partite entrano nel conto: cene, eventi e compleanni restano fuori
// anche se hanno risposte di presenza.
const conAltri: ContestoObiettivi = {
...contestoVuoto,
eventi: [
conv("t1", "2026-08-05", "allenamento", [g1!.id, g2!.id]),
conv("t2", "2026-08-06", "evento", [g1!.id, g2!.id]),
conv("t3", "2026-08-07", "compleanno", [g1!.id, g2!.id]),
],
presenze: {
t1: P([g1!.id, "presente"], [g2!.id, "assente"]),
t2: P([g1!.id, "assente"], [g2!.id, "assente"]),
t3: P([g1!.id, "presente"], [g2!.id, "presente"]),
},
};
assert.equal(o1v(conAltri), 50, "o1: solo l'allenamento conta (1 su 2)");
assert.equal(o7v(conAltri), 50, "o7: solo l'allenamento conta (1 su 2)");
// Assente, forse, infortunato e nessuna risposta sono tutti presenze perse: posto vuoto.
const perse: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("p1", "2026-08-05", "partita", [g1!.id, g2!.id, g3!.id, g4!.id])],
presenze: { p1: P([g1!.id, "assente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) },
};
assert.equal(o1v(perse), 0, "o1: nessuna presenza, 0 su 4");
assert.equal(o7v(perse), 0, "o7: nessuna presenza, 0 su 4");
const unaPresente = {
...perse,
presenze: { p1: P([g1!.id, "presente"], [g2!.id, "forse"], [g3!.id, "infortunato"]) },
};
assert.equal(o1v(unaPresente), 25, "o1: 1 presente su 4 convocati");
assert.equal(o7v(unaPresente), 25, "o7: 1 presente su 4 convocati");
// Un non convocato che risponde non gonfia la percentuale, e se è assente non la abbassa.
const nonConv: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("n1", "2026-08-05", "allenamento", [g1!.id])],
presenze: { n1: P([g1!.id, "presente"], [g2!.id, "assente"], [g3!.id, "presente"]) },
};
assert.equal(o1v(nonConv), 100, "o1: l'assente non convocato non pesa, il presente nemmeno");
assert.equal(o7v(nonConv), 100, "o7: idem");
// Il mese e il totale coincidono quando tutti gli eventi sono del mese corrente;
// il totale invece comprende anche gli altri mesi.
const dueMesi: ContestoObiettivi = {
...contestoVuoto,
eventi: [
conv("m1", "2026-08-05", "allenamento", [g1!.id, g2!.id]),
conv("m2", "2026-07-05", "allenamento", [g1!.id, g2!.id]),
],
presenze: {
m1: P([g1!.id, "presente"], [g2!.id, "presente"]),
m2: P([g1!.id, "assente"], [g2!.id, "assente"]),
},
};
assert.equal(o1v(dueMesi), 100, "o1: guarda solo agosto");
assert.equal(o7v(dueMesi), 50, "o7: guarda agosto e luglio insieme");
assert.equal(o1v(dueMesi, new Date("2026-07-15T10:00:00Z")), 0, "o1 a luglio: solo luglio");
const soloAgosto = { ...dueMesi, eventi: [dueMesi.eventi[0]!] };
assert.equal(o1v(soloAgosto), o7v(soloAgosto), "stessi eventi: stessa percentuale");
// Confine di mese nel fuso Europe/Rome: il 30 giugno alle 22:30 UTC a Roma è già luglio.
const sera = new Date("2026-06-30T22:30:00Z");
const luglio: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("l1", "2026-07-01", "allenamento", [g1!.id])],
presenze: { l1: P([g1!.id, "presente"]) },
};
assert.equal(o1v(luglio, sera), 100, "a Roma è già il 1° luglio: l'evento di luglio conta");
const giugno = { ...luglio, eventi: [conv("l1", "2026-06-30", "allenamento", [g1!.id])] };
assert.equal(o1v(giugno, sera), 0, "a Roma giugno è finito: l'evento del 30 giugno non conta");
// Convocato sconosciuto (non in rosa): nessun posto, quindi 0 e non NaN.
const fantasma: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("f1", "2026-08-05", "allenamento", ["fantasma"])],
presenze: { f1: P(["fantasma", "presente"]) },
};
assert.equal(o1v(fantasma), 0, "o1: convocato fuori rosa, 0 posti");
assert.equal(o7v(fantasma), 0, "o7: convocato fuori rosa, 0 posti");
// Evento senza nessuna risposta: i posti ci sono, le presenze no.
const senzaRisposte: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("r1", "2026-08-05", "allenamento", [g1!.id, g2!.id])],
};
assert.equal(o1v(senzaRisposte), 0, "o1: nessuna risposta = 0%");
// Rosa vuota: nessuna divisione per zero.
assert.equal(
trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o1").valore,
0,
"rosa vuota: o1",
);
assert.equal(
trova(obiettiviSquadra([], conAltri, OGGI_AGOSTO), "o7").valore,
0,
"rosa vuota: o7",
);
// Arrotondamento: 2 su 3 = 67%, e il progresso non supera mai il 100%.
const treConvocati: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("a3", "2026-08-05", "allenamento", [g1!.id, g2!.id, g3!.id])],
presenze: { a3: P([g1!.id, "presente"], [g2!.id, "ritardo"], [g3!.id, "assente"]) },
};
assert.equal(o1v(treConvocati), 67, "o1: 2 su 3 arrotondato");
assert.equal(o7v(treConvocati), 67, "o7: 2 su 3 arrotondato");
// Soglia del 90%: la percentuale arriva al target e l'obiettivo risulta completato.
const dieci = giocatori.slice(0, 10).map((g) => g.id);
const novePresenti: ContestoObiettivi = {
...contestoVuoto,
eventi: [conv("d1", "2026-08-05", "allenamento", dieci)],
presenze: {
d1: Object.fromEntries(dieci.map((id, i) => [id, i === 0 ? "assente" : "presente"])),
},
};
const ob1 = trova(obiettiviSquadra(giocatori, novePresenti, OGGI_AGOSTO), "o1");
assert.equal(ob1.valore, 90, "9 su 10 = 90%");
assert.equal(progressoObiettivo(ob1), 100, "90% su target 90% = completato");
}
// --- o1: si azzera a ogni cambio mese, in base agli eventi a calendario ------
{
// Stesso evento/presenze: "in mese" a settembre, "fuori mese" se letto da agosto.
@@ -529,6 +692,7 @@ const o = (valore: number, target: number): ObiettivoSquadra => ({
unita: "%",
emoji: "🎯",
impatto: "i",
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
});
assert.equal(progressoObiettivo(o(0, 10)), 0);
assert.equal(progressoObiettivo(o(5, 10)), 50);
@@ -558,4 +722,17 @@ assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.")
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo.");
// --- dettaglio: ogni obiettivo spiega tutto, senza campi vuoti ---------------------
for (const ob of obiettiviSquadra(giocatori, contestoVuoto)) {
const d = ob.dettaglio;
for (const campo of [d.comeSiCalcola, d.periodo, d.fonte, d.esempio]) {
assert.ok(campo.trim().length > 0, `${ob.id}: testo di dettaglio vuoto`);
}
assert.ok(d.conta.length > 0, `${ob.id}: manca «cosa conta»`);
assert.ok(d.nonConta.length > 0, `${ob.id}: manca «cosa non conta»`);
if (ob.id === "o1" || ob.id === "o7") {
assert.ok(d.perse && d.perse.length > 0, `${ob.id}: manca «presenza persa»`);
}
}
console.log("obiettivi: ok");