1 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5.5 f49351e536 Avviso certificati: mostra anche chi non l'ha caricato (DD-046), versione 1.2.3
Il giocatore senza certificato vede una card rossa cliccabile, l'admin
l'elenco dei mancanti sopra scaduti e in scadenza. Nessuna migration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:15:17 +02:00
64 changed files with 2803 additions and 2823 deletions

No files matched your search

+7 -8
View File
@@ -1,6 +1,6 @@
# Project State
Ultimo aggiornamento: 06/10/2026
Ultimo aggiornamento: 01/10/2026
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
@@ -9,18 +9,17 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
## Stato generale
- **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).
- **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.
- **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
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
`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 `main` dalla 1.3.0.
è su `develop`, non ancora su `main`.
- **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).
+1 -5
View File
@@ -85,13 +85,9 @@ quando il database non risponde.
## UI
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
`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
`Section`, `StatTile`), 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
+1 -48
View File
@@ -13,62 +13,15 @@ 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 «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
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration.
### Modificato
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
### Sicurezza
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
## [1.2.2] - 2026-10-01
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
+12 -14
View File
@@ -22,7 +22,6 @@ sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
@@ -59,7 +58,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` | 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. |
| `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. |
| `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. |
@@ -122,11 +121,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
- Il `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
`sollecita_presenze` restano i tipi dei pulsanti manuali.
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
automatici (compleanni compresi) ma non quelli dei pulsanti.
automatici ma non quelli dei pulsanti.
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
@@ -152,16 +151,15 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
| Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
| Funzione (migration) | Cosa fa | Job `pg_cron` |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
## Badge
+2 -5
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 ed export CSV; il tracciamento di numero e data
di tessera è stato tolto dalla dashboard (DD-048)
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Collegamento CSI (stagione 2025/26)
- [x] Classifica automatica (campionato e Coppa)
- [x] Risultati campionato
@@ -47,9 +47,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
## Prossimo
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
-65
View File
@@ -1,65 +0,0 @@
# DD-047 — Notifica di compleanno
**Data:** 6 ottobre 2026
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
**Contesto**
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
**Decisione**
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
`notifiche_utente`, il worker `mailer/` manda push ed email.
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
mancato si recupera all'ora dopo e non si genera mai due volte.
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
nessun avviso.
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
senza i nomi degli altri.
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
`eventi_app`.
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
**Alternative scartate**
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
chiede la issue.
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
**Conseguenze**
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
destinatari). Chi la inserisce prima delle 8 viene incluso.
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
- Il compleanno resta anche in Calendario, invariato.
**Riesame**
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
-37
View File
@@ -1,37 +0,0 @@
# 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
@@ -1,53 +0,0 @@
# 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.
-3
View File
@@ -86,9 +86,6 @@ 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 |
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
+1 -2
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 💩» |
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
| `/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) |
@@ -132,7 +132,6 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
| Turno palloni | no | I turni riguardano solo i giocatori. |
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
+1 -1
View File
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
## Quali notifiche e a chi
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
+21 -32
View File
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
indipendenti:
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
serie e obiettivi appena raggiunti; non è un canale push separato.
@@ -34,20 +34,19 @@ in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
quelle del turno palloni**, che restano solo push e in-app: DD-040).
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
automatiche (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
lo stesso evento (vedi «Centro notifiche in-app»).
@@ -64,17 +63,15 @@ volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato d
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
| # | Notifica | Titolo | Corpo |
| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
| # | Notifica | Titolo | Corpo |
| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
@@ -106,7 +103,6 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
- Assenze e infortuni.
- La registrazione o il collegamento di un nuovo giocatore.
- Il certificato in scadenza: resta una card in Home (DD-035).
- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047).
### Comportamenti da conoscere
@@ -147,12 +143,6 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
- **Per ogni evento un giocatore riceve al massimo una mail automatica** (DD-045): il
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e
agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se
il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario.
Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro.
- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli
altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato.
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
@@ -166,7 +156,6 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
+25 -54
View File
@@ -2,8 +2,7 @@
**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()`),
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
---
@@ -38,27 +37,22 @@ corrente dinamico (vedi sotto).
## Obiettivi definiti
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
| `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` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `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` |
| id | Obiettivo | Calcolo | Target | Fonte |
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
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
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 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"
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
@@ -71,22 +65,7 @@ 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. 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,
`adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a
0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il
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` 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`.
seguono di conseguenza.
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
@@ -165,29 +144,21 @@ 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, 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) |
| 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) |
- **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,
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
(evento dentro/fuori mese), scadenza dinamica.
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
+8 -3
View File
@@ -328,6 +328,7 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
- Certificato medico
- Documento di identità
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
@@ -335,6 +336,7 @@ 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)
@@ -360,9 +362,12 @@ Campi esportati.
## Tracciamento tesseramento
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.
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.
## Completamento profilo
+8 -7
View File
@@ -48,19 +48,20 @@ 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»). La
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). 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 |
| 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`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
+2046 -249
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "crapp",
"version": "1.3.0",
"version": "1.2.3",
"private": true,
"sideEffects": false,
"type": "module",
@@ -20,10 +20,10 @@
"dependencies": {
"@supabase/supabase-js": "^2.111.0",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.104.1",
"@tanstack/react-router": "^1.170.41",
"@tanstack/react-start": "^1.168.60",
"@tanstack/router-plugin": "^1.168.42",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "^1.170.18",
"@tanstack/react-start": "^1.168.32",
"@tanstack/router-plugin": "^1.168.23",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
-127
View File
@@ -1,127 +0,0 @@
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>
);
}
+61 -28
View File
@@ -1,5 +1,5 @@
import { CalendarClock, FileClock, FileX } from "lucide-react";
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
import { AlertCircle } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { useProfili } from "@/lib/profili";
import {
avvisiCertificatiUtente,
@@ -12,6 +12,45 @@ 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.
@@ -33,31 +72,28 @@ export function AvvisoCertificati() {
base,
});
const cardPersonale = personaleMancante ? (
<Avviso
tono="critico"
icona={FileX}
<Card
colore="destructive"
titolo="Certificato mancante"
cliccabile
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
/>
) : personale ? (
personale.giorni < 0 ? (
<Avviso
tono="scaduto"
icona={FileClock}
<Card
colore="primary"
titolo="Certificato scaduto"
cliccabile
testi={[
righe={[
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
]}
/>
) : (
<Avviso
tono="attenzione"
icona={CalendarClock}
<Card
colore="warning"
titolo="Certificato in scadenza"
cliccabile
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
/>
)
) : null;
@@ -68,33 +104,30 @@ export function AvvisoCertificati() {
<>
{cardPersonale}
{mancanti.length > 0 ? (
<Avviso
tono="critico"
icona={FileX}
<Card
colore="destructive"
titolo="Certificati mancanti"
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
/>
) : null}
{scaduti.length > 0 ? (
<Avviso
tono="scaduto"
icona={FileClock}
<Card
colore="primary"
titolo="Certificati scaduti"
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
/>
) : null}
{inScadenza.length > 0 ? (
<Avviso
tono="attenzione"
icona={CalendarClock}
<Card
colore="warning"
titolo="Certificati in scadenza"
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
/>
) : null}
</>
);
}
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
return `${a.nome} ${a.cognome} — ${dettaglio}`;
}
+11 -13
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-[15px] leading-relaxed">
<DrawerDescription className="mt-1 text-sm leading-relaxed">
{def.descrizione}
</DrawerDescription>
</div>
@@ -42,15 +42,13 @@ 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Stato attuale
</p>
{grado ? (
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
{meta?.label}
</span>
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
) : (
<span className="text-[13px] font-bold uppercase text-muted-foreground">
<span className="text-xs font-bold uppercase text-muted-foreground">
In progresso
</span>
)}
@@ -61,19 +59,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
{stato.prossimaSoglia ? (
<>
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
<p className="mt-2 text-[13px] text-muted-foreground">
<p className="mt-2 text-xs 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-[13px] text-success">Hai raggiunto il livello massimo.</p>
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
)}
</div>
) : null}
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Soglie
</p>
<div className="grid grid-cols-3 gap-2">
@@ -90,7 +88,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
<p
className={cn(
"text-[13px] font-bold uppercase",
"text-xs font-bold uppercase",
raggiunto ? gm.text : "text-muted-foreground",
)}
>
@@ -104,7 +102,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
>
{def.soglie[g]}
</p>
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
<p className="text-xs text-muted-foreground">{def.unita}</p>
</div>
);
})}
@@ -112,7 +110,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
</div>
{def.celebrazione ? (
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
“{def.celebrazione}”
</p>
) : null}
@@ -144,7 +142,7 @@ export function BadgeDrawer({
<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"
className="w-full rounded-2xl bg-secondary py-3 text-sm 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-[13px] font-bold uppercase tracking-wide text-accent">
<p className="text-xs 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-[15px] text-muted-foreground">{notifica.testo}</p>
<p className="mt-1.5 text-sm 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-[15px] font-bold uppercase tracking-wide text-accent-foreground"
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
>
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
</button>
+24 -28
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-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
) : null}
</div>
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs 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-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
<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-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
)}
</Reveal>
);
@@ -90,15 +90,13 @@ 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-[15px] leading-relaxed">
<DrawerDescription className="mt-1 text-sm leading-relaxed">
{cat.descrizione}
</DrawerDescription>
</div>
</div>
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
Vinto
</p>
<p className="text-xs 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">
@@ -111,7 +109,7 @@ function SocialDrawer({
<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"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
>
Indietro
</button>
@@ -141,7 +139,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-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
Collezione badge
</p>
<p className="font-display text-4xl leading-none">
@@ -149,7 +147,7 @@ export function CollezioneBadge({
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
</p>
</div>
<p className="text-[13px] text-primary-foreground/70">
<p className="text-xs text-primary-foreground/70">
<Numero valore={pct} suffisso="%" /> completata
</p>
</div>
@@ -158,12 +156,12 @@ export function CollezioneBadge({
{vicino ? (
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
<p className="text-xs font-bold uppercase tracking-wide text-accent">
Prossimo traguardo
</p>
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
<p className="mt-2 text-[13px] text-muted-foreground">
<p className="mt-2 text-xs text-muted-foreground">
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
</p>
</div>
@@ -171,7 +169,7 @@ export function CollezioneBadge({
{c.sbloccati.length > 0 ? (
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Sbloccati ({c.sbloccati.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -186,7 +184,7 @@ export function CollezioneBadge({
{c.inProgresso.length > 0 ? (
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
In progresso ({c.inProgresso.length})
</p>
<div className="grid grid-cols-2 gap-2">
@@ -201,7 +199,7 @@ export function CollezioneBadge({
{socialVinti.length > 0 ? (
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Votati dai compagni
</p>
<div className="grid grid-cols-2 gap-2">
@@ -209,8 +207,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-[15px] font-bold leading-tight">{cat.nome}</p>
<p className="text-[13px] text-muted-foreground">
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
<p className="text-xs text-muted-foreground">
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
</p>
</div>
@@ -221,7 +219,7 @@ export function CollezioneBadge({
) : null}
<div>
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge segreti
</p>
<div className="grid grid-cols-2 gap-2">
@@ -231,8 +229,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-[15px] font-bold leading-tight">{b.def.nome}</p>
<p className="text-[13px] text-muted-foreground">
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
{b.def.celebrazione ?? "Badge segreto sbloccato."}
</p>
</div>
@@ -245,10 +243,8 @@ 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-[15px] font-bold leading-tight text-muted-foreground">
???
</p>
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
<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>
</div>
))}
</div>
+17 -19
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-[13px] text-muted-foreground">
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[15px]">
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
<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">
{g.numero}
</span>
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
{g.ruolo ? <span className="text-xs 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-[13px] text-muted-foreground">
<div key={i} className="flex items-center gap-2 text-xs 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-[13px] text-muted-foreground">
<div className="mt-3 space-y-0.5 text-xs 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-[13px]">
<div className="text-xs">
<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-[13px] font-bold">
<div className="flex items-center justify-between text-xs 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-[13px] text-muted-foreground">
<p className="mb-2 text-xs 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-[13px] font-bold text-success-foreground"
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
style={{ width: `${noi}%` }}
>
{noi.toFixed(0)}%
</div>
<div
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
className="flex items-center justify-center bg-destructive text-xs 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-[13px] text-muted-foreground">
<p className="text-xs 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,22 +248,20 @@ export function DettaglioCsiEsteso({
const { data, isLoading } = useCsiPartita(matchId);
if (isLoading) {
return (
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
);
return <p className="px-1 text-center text-xs 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-[13px] text-muted-foreground">
<p className="text-xs 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-[15px] font-bold">
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
<Users2 className="h-4 w-4 text-accent" /> Formazioni
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
@@ -274,7 +272,7 @@ export function DettaglioCsiEsteso({
) : null}
{data.precedenti ? (
<Card>
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
<div className="mb-3 flex items-center gap-2 text-sm 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-[15px] font-bold leading-tight">{evento.titolo}</h3>
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
</div>
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<div className="shrink-0 text-right text-xs 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-[13px] font-bold uppercase tracking-wide",
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
tipo.className,
)}
>
{tipo.label}
</span>
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm 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-[13px] text-muted-foreground">{sottotitoloPartita}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
) : null}
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] 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-[13px] text-muted-foreground">📝 {noteCard}</p>
<p className="mt-2.5 line-clamp-2 text-xs 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-[13px] font-semibold text-muted-foreground"
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"
aria-live="polite"
>
<span aria-hidden>{metaStato.emoji}</span>
-142
View File
@@ -1,142 +0,0 @@
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>
);
}
+10 -12
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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-xs 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-[13px] font-bold uppercase text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
</span>
</div>
<p className="mt-1 text-[13px] text-muted-foreground">
<p className="mt-1 text-xs 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-[13px] text-muted-foreground">
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
Carico le pagelle…
</p>
) : (
@@ -70,17 +70,15 @@ 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-[13px]" />
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-bold leading-tight">
{g.nome}
</span>
<span className="text-[13px] text-muted-foreground">
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="text-xs text-muted-foreground">
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
</span>
</span>
{sonoIo ? (
<span className="shrink-0 text-[13px] font-semibold uppercase text-muted-foreground">
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
Sei tu
</span>
) : chiuse ? (
@@ -91,7 +89,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-[13px] font-bold uppercase transition-colors disabled:opacity-50",
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
mio !== undefined
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
@@ -109,7 +107,7 @@ export function Pagelle({
type="button"
onClick={() => invia(g.id, v)}
className={cn(
"rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90",
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
mio === v
? "bg-accent text-accent-foreground"
: "bg-card text-foreground",
+11 -13
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-[15px] uppercase tracking-wide">{titolo}</h3>
<h3 className="font-display text-sm 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-[15px]">
<span className="flex min-w-0 items-center gap-2 text-sm">
<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-[13px] font-bold text-primary-foreground disabled:opacity-60"
className="premi rounded-xl bg-primary px-3 py-2 text-xs 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-[13px] font-bold tabular-nums text-muted-foreground">
<span className="shrink-0 text-xs 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-[15px] 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-sm 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-[13px] font-bold tabular-nums text-muted-foreground">
<span className="shrink-0 text-xs 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-[15px] 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-sm 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,9 +486,7 @@ export function ProfiloAmministrativo({
<SezioneTendina
titolo="Dati per il tesseramento"
indice={indice}
azione={
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
}
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
>
{corpo}
</SezioneTendina>
@@ -523,10 +521,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-[16px] uppercase tracking-wide">
<span className="font-display text-[15px] uppercase tracking-wide">
Completa il tuo profilo
</span>
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
@@ -534,7 +532,7 @@ export function CompletaProfilo({
style={{ width: `${perc}%` }}
/>
</div>
<p className="mt-2 text-[14px] text-muted-foreground">
<p className="mt-2 text-[13px] text-muted-foreground">
{allenatore
? "Data e luogo di nascita, telefono ed email."
: "Documento e certificato medico servono per il tesseramento CSI."}
+11 -13
View File
@@ -1,5 +1,4 @@
import { Volleyball } from "lucide-react";
import { Avviso } from "@/components/crapp/Avviso";
import { AlertCircle } from "lucide-react";
import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
@@ -18,8 +17,6 @@ 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;
@@ -39,7 +36,6 @@ 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(
@@ -48,13 +44,15 @@ export function PromemoriaPalloni() {
}
return (
<Avviso
tono="azione"
icona={Volleyball}
titolo="Turno palloni"
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
testi={messaggi}
riquadri
/>
<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>
);
}
+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-[15px] font-bold">
<p className="text-sm 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-[13px] font-bold",
"rounded-full px-2.5 py-1 text-xs 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-[13px] font-bold text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
"rounded-full border border-border px-2.5 py-1.5 text-xs 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-[15px] 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-sm 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-[13px] text-muted-foreground">
<p aria-busy="true" className="text-center text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="flex items-center gap-1.5 text-xs 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-[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>
<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>
</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-[13px] opacity-80">{sottotitolo}</span>
<span className="block text-xs 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-[15px] font-bold leading-tight">{s.def.label}</p>
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
<p className="text-xs 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-[13px] text-muted-foreground">
<p className="mt-2 text-xs 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-[15px] font-bold leading-tight">
<p className="text-sm font-bold leading-tight">
Serie {top.def.label.toLowerCase()}: {top.valore}
</p>
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
<p className="text-xs 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-[13px] font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<p className="mt-1 text-[13px] text-muted-foreground">
<p className="mt-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
💩 Sondaggio pre-partita
</p>
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
{dellaPartita.length} risposte
</span>
</div>
<p className="mt-1 text-[13px] text-muted-foreground">
<p className="mt-1 text-xs 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-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
"min-w-11 rounded-xl px-3 py-2 text-sm 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-[13px]">
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<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-[15px] 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-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
Palloni
</span>
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
<span className="flex items-center gap-1.5 text-sm 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-[13px] font-bold uppercase text-muted-foreground">
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs 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-[15px] transition-colors",
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm 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-[13px]" />
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
<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-[13px] 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-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<Vote className="h-3.5 w-3.5" /> Voto MVP
</p>
<p className="mt-1 text-[13px] text-muted-foreground">
<p className="mt-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-xs 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-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
>
{mio ? "Cambia voto" : "Vota"}
</button>
</div>
<p className="mt-2 text-[13px]">
<p className="mt-2 text-xs">
{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-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
) : !puoVotare ? (
<p className="mt-1 text-[13px] text-muted-foreground">
<p className="mt-1 text-xs 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-[13px] font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-xs 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-[13px] font-semibold">
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs font-semibold">
{c.nome} · {c.voti}
</span>
))}
+8 -10
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-[13px] font-semibold text-muted-foreground">
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs 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,16 +79,14 @@ export function VotoSocial({ matchId }: { matchId: string }) {
{cat.emoji}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-bold leading-tight">
{cat.nome}
</span>
<span className="block truncate text-[13px] text-muted-foreground">
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
<span className="block truncate text-xs 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-[13px] font-bold uppercase",
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
)}
>
@@ -107,7 +105,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-[13px] font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
mio?.votato_id === g.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
@@ -120,17 +118,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
) : (
<div className="border-t border-border px-3 py-2">
{totale === 0 ? (
<p className="text-[13px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
Nessun voto ancora: apri e scegli un compagno.
</p>
) : vincitore ? (
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold">
<p className="inline-flex items-center gap-1.5 text-xs 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-[13px] text-muted-foreground">
<p className="text-xs 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-[15px]"
className="touch-pan-y bg-card p-3 pr-8 text-sm"
>
<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-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
<p className="mt-1 text-xs 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-[15px] text-primary-foreground/80">{sottotitolo}</p>
<p className="mt-1 truncate text-sm 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-[15px]";
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
/**
* 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<span className="text-xs 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-[13px] font-bold uppercase",
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs 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-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
{hint ? <p className="mt-0.5 text-xs 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-[15px] text-muted-foreground", className)}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
+34
View File
@@ -222,6 +222,40 @@ 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`
+1 -3
View File
@@ -24,9 +24,7 @@ export type TipoNotifica =
| "sollecita_presenze_24h"
| "sollecita_presenze_12h"
| "sollecita_presenze_6h"
| "sondaggio_cacche"
| "compleanno"
| "compleanno_auguri";
| "sondaggio_cacche";
export type NotificaUtente = {
id: string;
+17 -239
View File
@@ -14,25 +14,6 @@ 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 = {
@@ -50,24 +31,6 @@ function meseCorrente(oggi: Date): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
}
/** Istante corrente in Europe/Rome come "YYYY-MM-DD HH:mm", confrontabile con data + ora di un evento. */
function adessoRoma(oggi: Date): string {
const p = Object.fromEntries(
new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Rome",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
})
.formatToParts(oggi)
.map((x) => [x.type, x.value]),
);
return `${p["year"]}-${p["month"]}-${p["day"]} ${p["hour"]}:${p["minute"]}`;
}
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
function aMese(oggi: Date): string {
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
@@ -86,30 +49,16 @@ function fineMese(oggi: Date): string {
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
}
/**
* 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 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;
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] ?? {};
presenti += attesi.filter((g) => {
const stato = risposte[g.id];
return stato === "presente" || stato === "ritardo";
}).length;
}
if (posti === 0) return 0;
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
}, 0);
return Math.round((presenti / posti) * 100);
}
@@ -130,6 +79,7 @@ export function obiettiviSquadra(
ctx: ContestoObiettivi = contestoVuoto,
oggi: Date = new Date(),
): ObiettivoSquadra[] {
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
const vittorie = ctx.vittorie ?? 0;
const mese = meseCorrente(oggi);
@@ -138,35 +88,12 @@ export function obiettiviSquadra(
id: "o1",
titolo: `90% di presenze ${aMese(oggi)}`,
descrizione: "Media presenze su partite e allenamenti del mese",
valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
valore: percentualePresenzeMese(ctx, rosa.length, 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",
@@ -178,55 +105,16 @@ 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: percentualePresenze(ctx, rosa),
target: 90,
unita: "%",
titolo: "250 presenze complessive",
descrizione: "Somma delle presenze di tutta la rosa in stagione",
valore: somma((g) => g.presenze),
target: 250,
unita: "presenze",
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",
@@ -237,19 +125,6 @@ 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",
@@ -260,16 +135,6 @@ 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",
@@ -280,21 +145,6 @@ 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",
@@ -305,22 +155,6 @@ 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",
@@ -331,22 +165,6 @@ 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",
@@ -357,56 +175,16 @@ 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",
titolo: "1 evento di squadra al mese",
descrizione: "Pizzate, cene e uscite fuori dal campo",
// Conta solo quando l'ora dell'evento è arrivata, non quando viene definito.
valore: ctx.eventi.filter(
(e) =>
e.tipo === "evento" &&
e.data.startsWith(mese) &&
`${e.data} ${e.ora || "00:00"}` <= adessoRoma(oggi),
).length,
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
target: 1,
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.",
},
},
];
}
+6 -6
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-[15px] text-muted-foreground">
<p className="mt-2 text-sm 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-[15px] 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-sm font-semibold text-primary-foreground"
>
Torna alla home
</Link>
@@ -43,7 +43,7 @@ function NotFoundComponent() {
);
}
function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
@@ -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-[15px] text-muted-foreground">
<p className="mt-2 text-sm 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-[15px] 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-sm 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-[15px] 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-sm font-semibold text-foreground"
>
Torna alla home
</a>
+95 -24
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
Bell,
BellOff,
ChevronDown,
@@ -32,9 +33,11 @@ import {
useGiocatoriSquadra,
useImpostaAttivo,
useSalvaDatiSquadra,
useSalvaTesseramento,
useScollegaAccount,
validaDatiSquadra,
type DatiSquadra,
type DatiTesseramento,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
@@ -107,11 +110,18 @@ 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,
@@ -121,7 +131,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
email: g.email,
tipo: g.tipo,
};
// L'allenatore non ha numero né ruolo in campo (DD-034).
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
const allenatore = isAllenatore(g);
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
@@ -157,6 +167,16 @@ 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);
@@ -184,7 +204,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-[15px] uppercase tracking-wide">Dati squadra</h3>
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
@@ -244,14 +264,54 @@ 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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs 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-[15px] uppercase tracking-wide">Dati personali</h3>
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
<CampiAllenatore
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
@@ -268,12 +328,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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs 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-[13px]">
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
<span className="min-w-0 text-muted-foreground">
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
</span>
@@ -344,7 +404,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-[13px] font-bold uppercase disabled:opacity-60",
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
statoClasse[stato],
)}
>
@@ -382,12 +442,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-[15px] tabular-nums">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm 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-[13px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
{ruoloVisibile(g)} · profilo {perc}%
</p>
</div>
@@ -426,6 +486,15 @@ 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>
)}
@@ -480,7 +549,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-[15px] font-bold uppercase"
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
</button>
@@ -489,7 +558,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-[15px] uppercase tracking-wide">
<h3 className="font-display text-sm uppercase tracking-wide">
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
</h3>
<Campo label="Tipo">
@@ -563,7 +632,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
setAperto(false);
setDati(datiVuoti);
}}
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-[13px] font-bold uppercase"
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
>
Annulla
</button>
@@ -571,7 +640,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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Aggiungi
</button>
@@ -598,7 +667,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-[13px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
</p>
</div>
@@ -606,7 +675,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-[13px] 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-xs font-bold text-success-foreground disabled:opacity-50"
>
<UserCheck className="h-3.5 w-3.5" /> Riattiva
</button>
@@ -653,7 +722,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-[15px] text-muted-foreground shadow-card">
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
<Lock className="h-4 w-4 shrink-0" />
Riservata agli amministratori della squadra.
</p>
@@ -674,6 +743,7 @@ function Dashboard() {
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
"valido",
).length;
const tesserati = attivi.filter((g) => g.numeroTessera).length;
const contenutoSquadra = (
<>
@@ -685,11 +755,12 @@ 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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
@@ -698,7 +769,7 @@ function Dashboard() {
);
const contenutoProfili = isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento…
</p>
) : (
@@ -715,7 +786,7 @@ function Dashboard() {
/>
))}
{allenatori.length > 0 ? (
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
) : null}
{allenatori.map((g, i) => (
<SchedaGiocatore
@@ -748,7 +819,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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
@@ -764,7 +835,7 @@ function Dashboard() {
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
@@ -784,7 +855,7 @@ function Dashboard() {
</div>
</>
) : (
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
Caricamento…
</p>
);
@@ -838,7 +909,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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -850,7 +921,7 @@ function Dashboard() {
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
+5 -7
View File
@@ -43,9 +43,7 @@ function AllenamentoDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-[15px] text-muted-foreground">
Allenamento non trovato
</p>
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
</div>
);
}
@@ -61,14 +59,14 @@ function AllenamentoDetail() {
<Dumbbell className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[15px] text-muted-foreground">
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
@@ -78,12 +76,12 @@ function AllenamentoDetail() {
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs 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-[15px] text-muted-foreground">
<p className="mt-2 text-center text-sm 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-[15px] 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-sm 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-[15px] text-muted-foreground">
<p className="mt-2 text-center text-sm 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-[15px] 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-sm font-bold uppercase shadow-card"
>
<LogOut className="h-4 w-4" /> Esci
</button>
</>
) : inAttesaCollegamento ? (
<p className="mt-2 text-center text-[15px] text-muted-foreground">
<p className="mt-2 text-center text-sm 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-[13px] font-bold text-muted-foreground">
<div className="grid grid-cols-7 gap-1 text-center text-xs 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-[15px] font-semibold",
"relative grid aspect-square place-items-center rounded-xl text-sm 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-[13px] text-muted-foreground">
<p className="mt-2 text-xs text-muted-foreground">
Scorri a destra o sinistra per cambiare mese.
</p>
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
<div className="mt-3 flex flex-wrap gap-3 text-xs 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-[15px] 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-[14px] 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-[15px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-sm 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-[15px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-sm 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-[15px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-sm 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-[13px] 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-xs 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-[13px] text-muted-foreground">{vuoto}</p>
<p className="px-3 py-4 text-center text-xs 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-[15px] 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-sm 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-[13px] text-muted-foreground">{r.giocate}</span>
<span className="text-center text-[13px] tabular-nums text-muted-foreground">
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
<span className="text-center text-xs 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-[13px]",
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
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-[15px] font-bold text-foreground">Coppa</h3>
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
<h3 className="mb-2 mt-4 px-1 text-[15px] font-bold text-foreground">Girone</h3>
<h3 className="mb-2 mt-4 px-1 text-sm 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-[13px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-center text-xs 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-[15px] font-bold text-foreground">Stagione {stagione}</h3>
<h3 className="px-1 text-sm 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-[15px] font-bold">{casa.nome}</p>
<p className="truncate text-sm 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-[15px] font-bold">{trasferta.nome}</p>
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
</div>
<p className="text-[13px] text-muted-foreground">
<p className="text-xs 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-[13px] font-semibold tabular-nums",
"rounded-lg px-2 py-1 text-xs 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-[13px] font-bold uppercase text-accent">
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
</span>
) : null}
+24 -26
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-[15px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-5 text-center text-sm 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-[13px] font-bold text-muted-foreground">
<div className="grid grid-cols-7 gap-1 text-center text-xs 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-[15px] font-semibold transition-transform active:scale-90",
"relative grid aspect-square place-items-center rounded-xl text-sm 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-[13px] text-muted-foreground">
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="mt-3 space-y-2 text-center text-[15px]">
<div className="mt-3 space-y-2 text-center text-sm">
<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-[13px] font-bold uppercase"
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">
<p className="mt-2 text-xs 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-[13px] font-bold uppercase transition-colors",
"rounded-full py-2 text-xs font-bold uppercase transition-colors",
categoriaEvento(bozza) === t.id
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
@@ -337,17 +337,15 @@ function GestioneEventi() {
</Campo>
) : (
<div className="min-w-0">
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<span className="text-xs 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-[15px] font-semibold">
{formatData(bozza.data)}
</span>
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
<button
type="button"
onClick={() => setCambiaGiorno(true)}
className="shrink-0 rounded-full px-2 py-1 text-[13px] font-bold uppercase text-accent active:scale-95"
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
>
Cambia
</button>
@@ -396,7 +394,7 @@ function GestioneEventi() {
type="button"
onClick={() => aggiorna({ casa: o.casa })}
className={cn(
"flex-1 rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
"flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors",
bozza.casa === o.casa
? "bg-card shadow-card text-foreground"
: "text-muted-foreground",
@@ -435,7 +433,7 @@ function GestioneEventi() {
})
}
className={cn(
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
scelto
? "bg-accent text-accent-foreground"
: "bg-secondary text-muted-foreground",
@@ -452,7 +450,7 @@ function GestioneEventi() {
<button
type="button"
onClick={() => setBozza(null)}
className="flex-1 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
Annulla
</button>
@@ -460,7 +458,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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
</button>
@@ -491,8 +489,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-[15px] font-bold leading-tight">{e.titolo}</p>
<p className="text-[13px] text-muted-foreground">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
{e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
@@ -515,7 +513,7 @@ function GestioneEventi() {
</div>
))
) : (
<p className="rounded-2xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
@@ -523,7 +521,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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
</button>
@@ -546,14 +544,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-[15px] 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-sm 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-[15px] font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
@@ -575,14 +573,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-[15px] 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-sm 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-[15px] font-bold uppercase text-foreground"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
@@ -608,7 +606,7 @@ function Interruttore({
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors",
"rounded-full px-3 py-1.5 text-xs 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-[15px] text-muted-foreground">Evento non trovato</p>
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[15px] text-muted-foreground">
<div className="mt-4 space-y-2 text-sm 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-[15px]">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
</div>
+27 -42
View File
@@ -9,7 +9,6 @@ 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";
@@ -86,7 +85,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-[13px] font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
<p className="text-xs 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>
@@ -100,13 +99,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-[13px] uppercase text-primary-foreground/80">In classifica</p>
<p className="text-xs 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-[13px] uppercase text-primary-foreground/80">Bilancio W-L</p>
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
</div>
{giocatore ? (
<div className="rounded-2xl bg-primary-foreground/10 p-3">
@@ -114,7 +113,7 @@ function Index() {
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-[13px] uppercase text-primary-foreground/80">Streak</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
) : null}
</div>
@@ -132,7 +131,7 @@ function Index() {
azione={
<Link
to="/calendario"
className="inline-flex items-center text-[15px] font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Calendario <ChevronRight className="h-4 w-4" />
</Link>
@@ -141,7 +140,7 @@ function Index() {
{prossimo ? (
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
) : (
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nessun impegno in programma.
</p>
)}
@@ -156,7 +155,7 @@ function Index() {
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
@@ -171,7 +170,7 @@ function Index() {
<Link
to="/classifica"
search={{ tab: "storico" }}
className="inline-flex items-center text-[15px] font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Storico <ChevronRight className="h-4 w-4" />
</Link>
@@ -183,10 +182,8 @@ function Index() {
<>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-[15px] font-bold">
CRAP Volley vs {ultima.avversario}
</p>
<p className="text-[13px] text-muted-foreground">
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
<p className="text-xs text-muted-foreground">
{ultima.casa ? "In casa" : "Trasferta"}
{ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}
</p>
@@ -199,7 +196,7 @@ function Index() {
{ultima.parziali.map((p, i) => (
<span
key={i}
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
>
{p[0]}-{p[1]}
</span>
@@ -220,7 +217,7 @@ function Index() {
);
})()
) : (
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nessun risultato disponibile.
</p>
)}
@@ -233,39 +230,27 @@ function Index() {
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[15px] font-semibold text-accent"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<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>
<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>
) : 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-[15px] text-muted-foreground">Partita non trovata</p>
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[13px] font-semibold tabular-nums"
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
>
{noi}-{loro}
</span>
+13 -15
View File
@@ -60,7 +60,7 @@ function PartitaDetail() {
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs 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-[15px] text-muted-foreground">
<div className="mt-4 space-y-2 text-sm 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-[15px]">
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs 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-[15px] font-bold">CRAP Volley</span>
<span className="text-sm 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-[15px] font-bold">{avversario}</span>
<span className="text-right text-sm font-bold">{avversario}</span>
</div>
<div className="mt-4 space-y-2">
{match.parziali.map(([noi, loro], i) => (
@@ -183,9 +183,7 @@ 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-[13px] font-semibold text-muted-foreground">
Set {i + 1}
</span>
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
<span className="font-display text-lg">{loro}</span>
</div>
))}
@@ -215,7 +213,7 @@ function PartitaDetail() {
)
) : (
<Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
risultato.
</p>
@@ -250,13 +248,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-[13px] font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{t.l}
</p>
</div>
))}
</div>
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Dettaglio giocatori (uso interno allenatori)
</p>
<div className="mt-2 space-y-1">
@@ -266,7 +264,7 @@ function PartitaDetail() {
return (
<div
key={gid}
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
>
<span className="min-w-0 flex-1 truncate font-semibold">
#{g.numero} {g.nome}
@@ -287,7 +285,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-[15px] 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-sm 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-[13px] font-bold uppercase tracking-wide"
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs 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-[15px] disabled:opacity-60"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-[13px] text-muted-foreground">
<span className="block text-xs 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-[15px] disabled:opacity-60"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Email</span>
<span className="block text-[13px] text-muted-foreground">
<span className="block text-xs 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-[15px] transition-colors hover:bg-accent/5"
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"
>
<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-[15px] transition-colors hover:bg-accent/5"
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"
>
<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-[15px] transition-colors hover:bg-accent/5"
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"
>
<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-[13px] text-muted-foreground">CrAPP v{APP_VERSION}</p>
<p className="text-center text-xs 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-[15px] 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-sm 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-[15px] text-muted-foreground">{testo}</p>
<p className="mt-2 text-sm 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-[15px] font-bold uppercase"
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm 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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
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"
>
{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-[13px] 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-xs 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-[13px] font-bold disabled:opacity-40"
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"
>
<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-[13px] font-bold"
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs 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-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
</div>
<p className="font-display text-2xl leading-none text-primary-foreground/50">
{setNostri}-{setLoro}
</p>
<div className="min-w-0">
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
<p className="truncate text-xs 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-[13px] font-semibold tabular-nums"
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs 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-[13px] font-bold leading-tight">{g.nome}</p>
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
<p
className={cn(
"text-[13px] tabular-nums",
"text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mb-2 text-xs 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-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
"rounded-2xl py-3 text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Ultime azioni
</p>
{ultime.length === 0 ? (
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p>
<p className="mt-2 text-xs 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-[13px]",
"flex items-center justify-between gap-2 px-1 text-xs",
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-[13px] font-bold uppercase",
"shrink-0 rounded-full px-2 py-0.5 text-xs font-bold uppercase",
azioniMeta[a.tipo].className,
)}
>
@@ -474,14 +474,14 @@ function ScoutBoard({
<section className="px-5 pt-4">
<Card>
<label className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<label className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Avversario
</label>
<input
value={avversario}
maxLength={40}
onChange={(e) => setAvversario(e.target.value)}
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]"
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
/>
<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-[13px] font-bold uppercase",
"flex-1 rounded-full py-1.5 text-xs 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-[15px] font-bold uppercase"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm 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-[15px] 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-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
<Save className="h-4 w-4" /> Fine partita
</button>
+56 -73
View File
@@ -1,12 +1,11 @@
import { useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
import { Cake, ChevronDown, 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,
@@ -51,7 +50,7 @@ import {
function RuoloBadge({ ruolo }: { ruolo: string }) {
return (
<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">
<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">
{ruolo}
</span>
);
@@ -147,7 +146,7 @@ function Squadra() {
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-bold leading-tight">
<span className="block truncate text-sm font-bold leading-tight">
{nomeCompleto(a)}
</span>
<span className="mt-1 flex items-center gap-2">
@@ -164,7 +163,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-[13px] text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
</p>
</div>
@@ -192,12 +191,10 @@ function Squadra() {
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-[15px] font-bold leading-tight">
{g.nome}
</span>
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={g.ruolo} />
<span className="text-[13px] text-muted-foreground">
<span className="text-xs text-muted-foreground">
{g.numero ? `#${g.numero}` : "n° da definire"}
</span>
</span>
@@ -232,7 +229,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-[13px] text-muted-foreground">
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
{g.nascita.slice(0, 4)}
</p>
@@ -246,11 +243,13 @@ function Squadra() {
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
...(sonoAllenatore ? [] : [{ l: "Cacche 💩", v: g.cacchePartita || "—" }]),
...(sonoAllenatore
? []
: [{ l: "Cacche/partita 💩", 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-[13px] font-semibold uppercase text-muted-foreground">
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
{s.l}
</p>
</div>
@@ -259,11 +258,11 @@ function Squadra() {
{sonoAllenatore ? null : (
<>
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
<p className="mt-4 text-xs 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-[13px] text-muted-foreground">
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
@@ -275,20 +274,17 @@ 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-[13px] font-bold leading-tight">
<p className="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-[13px] text-muted-foreground">
<p className="text-xs 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-[13px] font-bold uppercase",
meta.text,
)}
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
>
{meta.label}
</p>
@@ -330,8 +326,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-[15px] text-foreground/80">Classifica per</span>
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
<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">
{criteri.find((c) => c.id === criterio)?.label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -349,7 +345,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-[15px] font-bold",
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
criterio === c.id
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
@@ -372,13 +368,13 @@ function Squadra() {
{rank[i]}
</span>
<div className="min-w-0">
<p className="truncate text-[15px] font-bold">
<p className="truncate text-sm 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-[13px] text-muted-foreground">
<p className="truncate text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
</p>
</div>
@@ -418,14 +414,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-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
<p className="text-xs 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-[13px] text-primary-foreground/70">
<p className="text-xs text-primary-foreground/70">
{completati}/{obiettivi.length} completati
</p>
</div>
@@ -436,47 +432,38 @@ function Squadra() {
const pct = progressoObiettivo(o);
const fatto = pct >= 100;
return (
<Reveal key={o.id} indice={i}>
<ObiettivoDrawer obiettivo={o}>
<button
type="button"
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
<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
className={cn(
"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",
"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",
)}
>
<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>
{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>
</Reveal>
);
})}
@@ -496,10 +483,8 @@ 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-[15px] font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-[13px] text-muted-foreground">
{descrizioneSoglie(b)}
</p>
<p className="text-sm font-bold leading-tight">{b.nome}</p>
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
</div>
<div className="mt-2 grid grid-cols-3 gap-1.5">
{gradiOrdine.map((grado) => {
@@ -515,11 +500,9 @@ function Squadra() {
key={grado}
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
>
<p className={cn("text-[13px] font-bold uppercase", meta.text)}>
{meta.label}
</p>
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
<p className="text-[13px] text-muted-foreground">
<p className="text-xs text-muted-foreground">
{quanti}/{rosa.length}
</p>
</div>
@@ -1,156 +0,0 @@
-- M31 — Notifica di compleanno (DD-047, issue #11)
--
-- Ogni giorno, dalle 8:00 (fuso Europe/Rome), chi ha il compleanno riceve il solo titolo di auguri e tutti gli
-- altri membri attivi della squadra (allenatori compresi) ricevono il solo avviso «Oggi è il compleanno
-- di ...», senza corpo e senza età.
-- Se in un giorno compiono gli anni più persone, ogni destinatario riceve un solo messaggio: chi non
-- festeggia con tutti i nomi, ogni festeggiato solo i propri auguri.
--
-- Canali come le altre notifiche automatiche (DD-038): la riga in `notifiche_utente` genera la mail
-- (il trigger di M26 lascia passare i nuovi tipi) e, con M31, anche la push dalla coda.
--
-- 29 febbraio: il confronto è tra giorno e mese, quindi chi è nato il 29 febbraio viene avvisato solo
-- negli anni bisestili. Limite noto e accettato (DD-047): non c'è un ripiego al 28 febbraio.
-- --- Tipi di notifica --------------------------------------------------------------------------
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
tipo IN (
'admin',
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h',
'sondaggio_cacche',
'compleanno',
'compleanno_auguri'
)
);
-- --- Registro ----------------------------------------------------------------------------------
--
-- Una riga per destinatario e giorno: ricorda che l'avviso di quel giorno è già stato generato,
-- anche se il giocatore elimina la notifica (come `promemoria_eventi_generati`, DD-037). Non è legato
-- a un evento: i compleanni non sono `eventi_app`. Sparisce con il giocatore (DD-029).
CREATE TABLE public.compleanni_notificati (
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
giorno date NOT NULL,
generato_il timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (giocatore_id, giorno)
);
COMMENT ON TABLE public.compleanni_notificati IS
'Avvisi di compleanno già generati per destinatario e giorno: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M31, DD-047).';
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
ALTER TABLE public.compleanni_notificati ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.compleanni_notificati FROM PUBLIC, anon, authenticated;
GRANT ALL ON public.compleanni_notificati TO service_role;
-- --- Job ---------------------------------------------------------------------------------------
--
-- Gira ogni ora e agisce solo dalle 8:00 in poi, ora locale: un giro mancato si recupera all'ora
-- dopo e il registro impedisce di generare due volte lo stesso avviso. `pg_cron` ragiona in UTC e
-- l'ora legale sposta l'ora locale di una ora: per questo l'ora si legge qui, in `Europe/Rome`.
--
-- Il registro è per destinatario e giorno: chi entra in squadra o inserisce la data di nascita dopo
-- che gli avvisi del giorno sono partiti non genera un secondo giro (DD-047).
--
-- `p_adesso` esiste per i test, che simulano un'altra data; il job lo chiama senza argomenti.
CREATE OR REPLACE FUNCTION public.genera_avvisi_compleanno(p_adesso timestamptz DEFAULT now())
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
adesso timestamp := p_adesso AT TIME ZONE 'Europe/Rome';
oggi date := (p_adesso AT TIME ZONE 'Europe/Rome')::date;
BEGIN
IF extract(hour FROM adesso) < 8 THEN
RETURN;
END IF;
WITH festeggiati AS (
SELECT g.id, g.nome
FROM public.giocatori_squadra g
WHERE g.attivo = true
AND g.nascita IS NOT NULL
AND extract(month FROM g.nascita) = extract(month FROM oggi)
AND extract(day FROM g.nascita) = extract(day FROM oggi)
),
elenco AS (
SELECT array_agg(nome ORDER BY nome, id COLLATE "C") AS nomi
FROM festeggiati
),
nuovi AS (
INSERT INTO public.compleanni_notificati (giocatore_id, giorno)
SELECT g.id, oggi
FROM public.giocatori_squadra g
WHERE g.attivo = true
AND EXISTS (SELECT 1 FROM festeggiati)
ON CONFLICT DO NOTHING
RETURNING giocatore_id
)
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
SELECT n.giocatore_id, 'compleanno_auguri',
'Buon compleanno, ' || f.nome || '! 🎂',
''
FROM nuovi n
JOIN festeggiati f ON f.id = n.giocatore_id
UNION ALL
SELECT n.giocatore_id, 'compleanno',
'Oggi è il compleanno di '
|| CASE WHEN cardinality(e.nomi) = 1 THEN e.nomi[1]
ELSE array_to_string(e.nomi[1:cardinality(e.nomi) - 1], ', ')
|| ' e ' || e.nomi[cardinality(e.nomi)]
END,
''
FROM nuovi n
CROSS JOIN elenco e
WHERE NOT EXISTS (SELECT 1 FROM festeggiati f WHERE f.id = n.giocatore_id);
END;
$$;
REVOKE ALL ON FUNCTION public.genera_avvisi_compleanno(timestamptz) FROM PUBLIC, anon, authenticated;
GRANT EXECUTE ON FUNCTION public.genera_avvisi_compleanno(timestamptz) TO service_role;
SELECT cron.schedule(
'avvisi-compleanno',
'0 * * * *',
$$ SELECT public.genera_avvisi_compleanno() $$
);
-- --- Push: anche i compleanni ------------------------------------------------------------------
--
-- Nascono nel database come le altre notifiche automatiche: nessuna route le manda, quindi la push
-- passa dalla coda (DD-038). Stesso trigger di M26 con i due tipi nuovi.
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
AFTER INSERT ON public.notifiche_utente
FOR EACH ROW
WHEN (NEW.tipo IN (
'evento_promemoria_24h',
'evento_promemoria_3h',
'turno_palloni_12h',
'turno_palloni_6h',
'turno_palloni_3h',
'turno_palloni_revocato',
'sollecita_presenze_24h',
'sollecita_presenze_12h',
'sollecita_presenze_6h',
'compleanno',
'compleanno_auguri'
))
EXECUTE FUNCTION public.accoda_push_promemoria();
+6 -6
View File
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
## Struttura
| Cartella | Cosa verifica | Serve rete? |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `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 | — |
| 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 |
| `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), 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 | — |
## Server di sviluppo con Node vecchio
@@ -1,399 +0,0 @@
/**
* Notifica di compleanno (M31, DD-047, issue #11): `bun test/integration/notifiche-compleanno.test.ts`.
*
* Esegue il job vero del database (`genera_avvisi_compleanno`) simulando date diverse con il
* parametro `p_adesso`, e verifica:
*
* - l'ora: prima delle 8:00 (fuso Europe/Rome, anche con l'ora legale) non parte nulla;
* - un compleanno: auguri al festeggiato, avviso con il solo nome a tutti gli altri membri attivi
* (allenatore compreso), niente a chi non è attivo;
* - più compleanni lo stesso giorno: un solo messaggio per destinatario, con i nomi in ordine
* alfabetico («A e B», «A, B e C»); ogni festeggiato riceve solo i propri auguri;
* - una sola generazione per destinatario e giorno, anche se la notifica viene eliminata;
* - il canale: push ed email in coda;
* - il 29 febbraio: avvisato solo negli anni bisestili (limite noto, DD-047);
* - che il job sia pianificato ogni ora.
*
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4`, `g5`, `g6`, `g9` (inattivo) e
* `g10` (allenatore) della rosa seed, ripristinati alla fine, e date simulate nel 2027/2028 in cui
* nessun altro slot compie gli anni (lo verifica il test stesso).
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("notifiche di compleanno", "stack locale non attivo (npx supabase start)");
riepilogo("notifiche-compleanno");
} else {
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
console.log(`notifiche di compleanno su ${URL_BASE}`);
const PASSWORD = "prova-notifiche-compleanno-123";
const G4 = "g4";
const G5 = "g5";
const G6 = "g6";
const INATTIVO = "g9";
const ALLENATORE = "g10";
const TIPI = "compleanno,compleanno_auguri";
const rest = (percorso: string, token: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: token === SERVIZIO ? SERVIZIO : ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
async function leggi<T>(percorso: string): Promise<T[]> {
const res = await rest(percorso, SERVIZIO);
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
return (await res.json()) as T[];
}
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
const res = await rest(percorso, token, {
method: metodo,
body: corpo === undefined ? null : JSON.stringify(corpo),
});
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
}
/** Il job, come se «adesso» fosse `quando` (istante UTC). */
async function gira(quando: string) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ p_adesso: quando }),
});
if (!res.ok) throw new Error(`genera_avvisi_compleanno: ${res.status} ${await res.text()}`);
}
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
method: "POST",
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
},
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
});
const corpo = (await res.json()) as { id?: string };
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: { apikey: ANON, "content-type": "application/json" },
body: JSON.stringify({ email, password: PASSWORD }),
});
const t = (await login.json()) as { access_token?: string };
if (!t.access_token) throw new Error("accesso fallito");
return { id: corpo.id, token: t.access_token };
}
type Slot = { id: string; nome: string; nascita: string | null; attivo: boolean };
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
const slot = () => leggi<Slot>("giocatori_squadra?select=id,nome,nascita,attivo&order=id");
const notifiche = () =>
leggi<Riga>(
`notifiche_utente?tipo=in.(${TIPI})&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id`,
);
const registro = (giorno: string) =>
leggi<{ giocatore_id: string }>(
`compleanni_notificati?giorno=eq.${giorno}&select=giocatore_id&order=giocatore_id`,
);
const impostaNascita = (id: string, nascita: string | null) =>
scrivi(`giocatori_squadra?id=eq.${id}`, "PATCH", { nascita }, tokenAdmin);
const svuota = async () => {
await rest(`notifiche_utente?tipo=in.(${TIPI})`, SERVIZIO, { method: "DELETE" });
await rest("compleanni_notificati?giorno=gte.2000-01-01", SERVIZIO, { method: "DELETE" });
};
/** Chi compie gli anni nel giorno `iso` secondo il database: attivo e con lo stesso giorno e mese. */
const festeggiatiDi = async (iso: string) =>
(await slot())
.filter((s) => s.attivo && s.nascita?.slice(5) === iso.slice(5))
.map((s) => s.id)
.sort();
const attivi = async () =>
(await slot())
.filter((s) => s.attivo)
.map((s) => s.id)
.sort();
let tokenAdmin = "";
const idUtenti: string[] = [];
const originali = new Map<string, Record<string, unknown>>();
// Orari UTC corrispondenti a 07:30 e 08:30 a Roma: l'inverno è UTC+1, l'estate UTC+2.
const INVERNO = { prima: "T06:30:00Z", dopo: "T07:30:00Z" };
const ESTATE = { prima: "T05:30:00Z", dopo: "T06:30:00Z" };
try {
const admin = await creaUtente(`test-compleanno-admin-${Date.now()}@example.test`);
const allenatore = await creaUtente(`test-compleanno-allenatore-${Date.now()}@example.test`);
idUtenti.push(admin.id, allenatore.id);
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
tokenAdmin = admin.token;
for (const id of [G4, G5, G6, INATTIVO, ALLENATORE]) {
const [riga] = await leggi<Record<string, unknown>>(
`giocatori_squadra?id=eq.${id}&select=nascita,tipo,attivo,auth_user_id`,
);
originali.set(id, riga ?? { nascita: null, tipo: "giocatore", attivo: true });
}
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
await scrivi(
`giocatori_squadra?id=eq.${ALLENATORE}`,
"PATCH",
{ tipo: "allenatore", auth_user_id: allenatore.id },
tokenAdmin,
);
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
// Nessuno dei cinque slot di prova parte con una nascita che possa confondere i casi.
for (const id of [G4, G5, G6, INATTIVO]) await impostaNascita(id, null);
await svuota();
const tutti = await slot();
const nome = (id: string) => tutti.find((s) => s.id === id)!.nome;
const attiviIds = await attivi();
assert.ok(attiviIds.includes(ALLENATORE), "l'allenatore è attivo");
assert.ok(!attiviIds.includes(INATTIVO), "g9 è inattivo");
// --- Un compleanno ---------------------------------------------------------------------
// 15 marzo 2027, orario invernale: 07:30 a Roma è prima, 08:30 è dopo.
const GIORNO1 = "2027-03-15";
await impostaNascita(G4, "1995-03-15");
await prova("prima delle 8:00 a Roma non parte nulla, il registro resta vuoto", async () => {
assert.deepEqual(await festeggiatiDi(GIORNO1), [G4], "solo g4 compie gli anni quel giorno");
await gira(`${GIORNO1}${INVERNO.prima}`);
assert.equal((await notifiche()).length, 0);
assert.equal((await registro(GIORNO1)).length, 0);
});
await prova(
"dalle 8:00: auguri al festeggiato, avviso con il solo nome a tutti gli altri",
async () => {
await gira(`${GIORNO1}${INVERNO.dopo}`);
const righe = await notifiche();
const mie = righe.filter((r) => r.giocatore_id === G4);
assert.equal(mie.length, 1);
assert.equal(mie[0]!.tipo, "compleanno_auguri");
assert.equal(mie[0]!.titolo, `Buon compleanno, ${nome(G4)}! 🎂`);
assert.equal(mie[0]!.corpo, "", "solo il titolo");
const altri = righe.filter((r) => r.giocatore_id !== G4);
assert.deepEqual(
altri.map((r) => r.giocatore_id).sort(),
attiviIds.filter((id) => id !== G4),
"tutti gli altri membri attivi, allenatore compreso, e nessun inattivo",
);
for (const r of altri) {
assert.equal(r.tipo, "compleanno");
assert.equal(r.titolo, `Oggi è il compleanno di ${nome(G4)}`);
assert.equal(r.corpo, "", "solo il titolo, niente età");
}
assert.equal(righe.length, attiviIds.length, "una notifica a testa");
assert.deepEqual(
(await registro(GIORNO1)).map((r) => r.giocatore_id),
attiviIds,
);
},
);
await prova("l'allenatore riceve l'avviso, l'inattivo niente", async () => {
const righe = await notifiche();
assert.equal(righe.filter((r) => r.giocatore_id === ALLENATORE).length, 1);
assert.equal(righe.filter((r) => r.giocatore_id === INATTIVO).length, 0);
});
await prova("push ed email vanno in coda per ogni notifica", async () => {
const ids = (await notifiche()).map((r) => r.id);
const mail = await leggi<{ notifica_id: string }>(
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
const push = await leggi<{ notifica_id: string }>(
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
);
assert.equal(mail.length, ids.length, "una mail per notifica");
assert.equal(push.length, ids.length, "una push per notifica");
});
await prova("un secondo giro lo stesso giorno non genera doppioni", async () => {
await gira(`${GIORNO1}T09:30:00Z`);
assert.equal((await notifiche()).length, attiviIds.length);
});
await prova("eliminare la notifica non la fa tornare", async () => {
const [prima] = await notifiche();
await scrivi(`notifiche_utente?id=eq.${prima!.id}`, "DELETE");
await gira(`${GIORNO1}T10:30:00Z`);
assert.equal((await notifiche()).length, attiviIds.length - 1);
});
await prova("un giorno senza compleanni non genera nulla, né registro", async () => {
await svuota();
const giorno = "2027-03-20";
assert.deepEqual(await festeggiatiDi(giorno), [], "nessuno compie gli anni il 20 marzo");
await gira(`${giorno}${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0);
assert.equal((await registro(giorno)).length, 0);
});
// --- Ora legale ------------------------------------------------------------------------
await prova("con l'ora legale le 8:00 di Roma sono le 6:00 UTC", async () => {
await svuota();
const giorno = "2027-07-15";
await impostaNascita(G4, "1995-07-15");
assert.deepEqual(await festeggiatiDi(giorno), [G4]);
await gira(`${giorno}${ESTATE.prima}`); // 07:30 a Roma
assert.equal((await notifiche()).length, 0, "alle 7:30 a Roma non parte ancora");
await gira(`${giorno}${ESTATE.dopo}`); // 08:30 a Roma
assert.equal((await notifiche()).length, attiviIds.length);
});
// --- Più compleanni lo stesso giorno ---------------------------------------------------
await prova(
"due compleanni: un solo messaggio a testa, nomi in ordine alfabetico",
async () => {
await svuota();
const giorno = "2027-03-16";
await impostaNascita(G4, "1995-03-16");
await impostaNascita(G5, "1990-03-16");
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5]);
await gira(`${giorno}${INVERNO.dopo}`);
const righe = await notifiche();
assert.equal(righe.length, attiviIds.length, "un solo messaggio per destinatario");
const nomi = [nome(G4), nome(G5)].sort((a, b) => a.localeCompare(b, "it"));
for (const id of [G4, G5]) {
const r = righe.find((x) => x.giocatore_id === id)!;
assert.equal(r.tipo, "compleanno_auguri");
assert.equal(r.titolo, `Buon compleanno, ${nome(id)}! 🎂`);
assert.equal(r.corpo, "", "solo il titolo");
assert.ok(!r.titolo.includes(nome(id === G4 ? G5 : G4)), "niente nomi degli altri");
}
const altri = righe.filter((r) => ![G4, G5].includes(r.giocatore_id));
assert.equal(altri.length, attiviIds.length - 2);
for (const r of altri) {
assert.equal(r.tipo, "compleanno");
assert.equal(r.titolo, `Oggi è il compleanno di ${nomi[0]} e ${nomi[1]}`);
assert.equal(r.corpo, "", "solo il titolo");
}
},
);
await prova("tre compleanni: «A, B e C»", async () => {
await svuota();
const giorno = "2027-03-17";
await impostaNascita(G4, "1995-03-17");
await impostaNascita(G5, "1990-03-17");
await impostaNascita(G6, "2000-03-17");
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5, G6]);
await gira(`${giorno}${INVERNO.dopo}`);
const nomi = [nome(G4), nome(G5), nome(G6)].sort((a, b) => a.localeCompare(b, "it"));
const altro = (await notifiche()).find((r) => ![G4, G5, G6].includes(r.giocatore_id))!;
assert.equal(altro.titolo, `Oggi è il compleanno di ${nomi[0]}, ${nomi[1]} e ${nomi[2]}`);
assert.equal(altro.corpo, "", "solo il titolo");
});
await prova("un festeggiato inattivo non conta: né avviso né auguri", async () => {
await svuota();
const giorno = "2027-03-18";
for (const id of [G4, G5, G6]) await impostaNascita(id, null);
await impostaNascita(INATTIVO, "2003-03-18");
assert.deepEqual(await festeggiatiDi(giorno), [], "g9 è inattivo");
await gira(`${giorno}${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0);
});
// --- 29 febbraio: limite noto (DD-047) -------------------------------------------------
await prova("chi è nato il 29 febbraio è avvisato solo negli anni bisestili", async () => {
await svuota();
await impostaNascita(INATTIVO, null);
await impostaNascita(G4, "2000-02-29");
// 2027 non è bisestile: né il 28 febbraio né il 1° marzo.
await gira(`2027-02-28${INVERNO.dopo}`);
await gira(`2027-03-01${INVERNO.dopo}`);
assert.equal((await notifiche()).length, 0, "negli anni normali non parte nulla");
// 2028 è bisestile: il 29 febbraio sì.
assert.deepEqual(await festeggiatiDi("2028-02-29"), [G4]);
await gira(`2028-02-29${INVERNO.dopo}`);
const righe = await notifiche();
assert.equal(righe.length, attiviIds.length);
assert.equal(righe.find((r) => r.giocatore_id === G4)!.tipo, "compleanno_auguri");
});
// --- Pianificazione --------------------------------------------------------------------
await prova("il job è pianificato ogni ora", async () => {
const contenitore = spawnSync(
"docker",
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
{ encoding: "utf8" },
)
.stdout.trim()
.split("\n")[0];
assert.ok(contenitore, "container del database locale non trovato");
const esito = spawnSync(
"docker",
[
"exec",
contenitore,
"psql",
"-U",
"postgres",
"-At",
"-c",
"select schedule from cron.job where jobname = 'avvisi-compleanno'",
],
{ encoding: "utf8" },
);
assert.equal(esito.status, 0, esito.stderr);
assert.equal(esito.stdout.trim(), "0 * * * *");
});
await prova("il job non è chiamabile dai client", async () => {
for (const token of [ANON, admin.token]) {
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
method: "POST",
headers: {
apikey: ANON,
Authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: "{}",
});
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterlo eseguire`);
}
});
} finally {
await svuota();
if (tokenAdmin) {
for (const [id, valori] of originali) {
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
method: "PATCH",
body: JSON.stringify(valori),
});
}
}
for (const id of idUtenti) {
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
method: "DELETE",
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
});
}
riepilogo("notifiche-compleanno");
}
}
+80 -159
View File
@@ -2,7 +2,7 @@
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
* su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
*
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
@@ -23,7 +23,7 @@
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import { obiettiviSquadra } from "@/lib/obiettivi";
import { serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
@@ -282,51 +282,33 @@ if (!locale) {
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
async () => {
const pizzataId = `${PREFISSO}-pizzata`;
const pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
// OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
for (const [id, giorno] of [
[pizzataId, "10"],
[pizzataFuturaId, "20"],
] as const) {
const inserita = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id,
tipo: "evento",
titolo: "Test obiettivi o6",
data: `${MESE_TEST}-${giorno}`,
ora: "20:30",
}),
});
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
}
const inserita = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: pizzataId,
tipo: "evento",
titolo: "Test obiettivi o6",
data: `${MESE_TEST}-20`,
}),
});
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
const tutti = await leggiEventi();
const o6Di = (ids: string[], oggi: Date) =>
obiettiviSquadra(
giocatori,
{ eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
oggi,
).find((o) => o.id === "o6")!;
const eventiReali = await leggiEventi();
const o6InMese = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: {}, pagelle: [] },
OGGI,
).find((o) => o.id === "o6")!;
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
const o6MeseDopo = obiettiviSquadra(
giocatori,
{ eventi: eventiReali, presenze: {}, pagelle: [] },
OGGI_MESE_DOPO,
).find((o) => o.id === "o6")!;
assert.equal(
o6Di([pizzataId], OGGI).valore,
1,
"l'evento sociale già passato conta, letto dal database",
);
assert.equal(
o6Di([pizzataFuturaId], OGGI).valore,
0,
"l'evento sociale futuro non conta finché non arriva la sua ora",
);
// Il 20 marzo 2099 alle 20:30 Roma (CET, UTC+1) = 19:30Z: da lì conta.
assert.equal(
o6Di([pizzataFuturaId], new Date("2099-03-20T19:30:00Z")).valore,
1,
"arrivata l'ora dell'evento conta",
);
assert.equal(
o6Di([pizzataId], new Date("2099-04-15T10:00:00Z")).valore,
o6MeseDopo.valore,
0,
"lo stesso evento non conta più il mese successivo (si azzera)",
);
@@ -404,129 +386,68 @@ if (!locale) {
},
);
await prova(
"o7 percentuale di presenze collettive da eventi/risposte reali del database",
async () => {
// o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
// convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
const allenamentoId = `${PREFISSO}-o7-allenamento`;
const partitaId = `${PREFISSO}-o7-partita`;
const [g1, g2, g3] = giocatori;
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
const allenamentoId = `${PREFISSO}-o7-allenamento`;
const partitaId = `${PREFISSO}-o7-partita`;
const OGGI_STR = "2099-01-20";
for (const [id, tipo, data] of [
[allenamentoId, "allenamento", "2099-01-05"],
[partitaId, "partita", "2099-01-08"],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id,
tipo,
titolo: `Test obiettivi o7 (${tipo})`,
data,
convocati: [g1!.id, g2!.id, g3!.id],
}),
});
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
// g1 presente a entrambi, g2 presente e in ritardo (il ritardo conta), g3 assente a
// entrambi: 4 presenze su 6 posti (3 convocati x 2 eventi) = 67%.
const righe = [
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
];
const inserite = await rest("risposte_presenze", {
for (const [id, tipo, data] of [
[allenamentoId, "allenamento", "2099-01-05"],
[partitaId, "partita", "2099-01-08"],
] as const) {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify(righe),
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
});
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
}
const eventiReali = (await leggiEventi()).filter(
(e) => e.id === allenamentoId || e.id === partitaId,
);
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(partitaId)),
};
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
// il ritardo conta). g3: assente a entrambi (0 presenze).
const [g1, g2, g3] = giocatori;
const righe = [
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
{ evento_id: partitaId, giocatore_id: g3!.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 o7 = obiettiviSquadra([g1!, g2!, g3!], {
eventi: eventiReali,
presenze: presenzeReali,
pagelle: [],
}).find((o) => o.id === "o7")!;
const eventiReali = (await leggiEventi()).filter(
(e) => e.id === allenamentoId || e.id === partitaId,
);
const presenzeReali = {
...(await leggiPresenze(allenamentoId)),
...(await leggiPresenze(partitaId)),
};
assert.equal(
o7.valore,
67,
"4 presenze su 6 posti (il ritardo conta), calcolate dal database",
);
},
);
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
...g,
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
}));
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 o7 = obiettiviSquadra(rosaConPresenzeReali, {
eventi: [],
presenze: {},
pagelle: [],
}).find((o) => o.id === "o7")!;
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",
);
},
);
assert.equal(
o7.valore,
4,
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
);
});
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
const matchId = `${PREFISSO}-o12-m1`;
-84
View File
@@ -1,84 +0,0 @@
/** 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,7 +69,6 @@ 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")),
-9
View File
@@ -59,8 +59,6 @@ const tipiNuovi: TipoNotifica[] = [
"sollecita_presenze_24h",
"sollecita_presenze_12h",
"sollecita_presenze_6h",
"compleanno",
"compleanno_auguri",
];
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
for (const tipo of tipiNuovi) {
@@ -69,13 +67,6 @@ for (const tipo of tipiNuovi) {
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
}
// Compleanno (DD-047): titolo e basta, il corpo è vuoto e resta vuoto (la riga non lo inventa).
for (const tipo of ["compleanno", "compleanno_auguri"] as const) {
const n = daRiga({ ...riga, tipo, titolo: "Buon compleanno, Marco! 🎂", corpo: "" });
assert.equal(n.tipo, tipo);
assert.equal(n.corpo, "", `${tipo}: corpo vuoto`);
}
// Le righe del corpo vanno a capo solo se la riga della notifica lo chiede al CSS: senza
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
const uiBits = readFileSync(
-106
View File
@@ -1,106 +0,0 @@
/** 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");
+17 -335
View File
@@ -100,169 +100,6 @@ 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.
@@ -379,7 +216,7 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
{
const OGGI_SETTEMBRE = new Date("2026-09-20T10:00:00Z");
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
const pizzataSettembre: ContestoObiettivi = {
eventi: [evento("p1", "2026-09-12", "evento")],
presenze: {},
@@ -396,85 +233,6 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
"lo stesso evento non conta se oggi è agosto",
);
// Definito ma non ancora arrivato: non conta finché non scatta l'ora dell'evento.
const pizzataFutura: ContestoObiettivi = {
eventi: [{ ...evento("pf1", "2026-09-28", "evento"), ora: "20:30" }],
presenze: {},
pagelle: [],
};
assert.equal(
trova(obiettiviSquadra(giocatori, pizzataFutura, OGGI_SETTEMBRE), "o6").valore,
0,
"un evento futuro non completa l'obiettivo al momento della definizione",
);
const serata = { ...evento("ps1", "2026-09-20", "evento"), ora: "20:30" };
const conSerata: ContestoObiettivi = { eventi: [serata], presenze: {}, pagelle: [] };
// 20/09 10:00Z = 12:00 a Roma (CEST): prima delle 20:30 non conta, dopo sì.
assert.equal(
trova(obiettiviSquadra(giocatori, conSerata, OGGI_SETTEMBRE), "o6").valore,
0,
"stesso giorno ma ora non ancora arrivata: non conta",
);
assert.equal(
trova(obiettiviSquadra(giocatori, conSerata, new Date("2026-09-20T18:30:00Z")), "o6").valore,
1,
"arrivata l'ora dell'evento (20:30 a Roma): conta",
);
// Senza ora vale 00:00: conta dal giorno stesso, non il giorno prima.
const senzaOra: ContestoObiettivi = {
eventi: [{ ...evento("so1", "2026-09-20", "evento"), ora: "" }],
presenze: {},
pagelle: [],
};
assert.equal(
trova(obiettiviSquadra(giocatori, senzaOra, OGGI_SETTEMBRE), "o6").valore,
1,
"evento senza ora: conta dal giorno stesso",
);
assert.equal(
trova(obiettiviSquadra(giocatori, senzaOra, new Date("2026-09-19T21:00:00Z")), "o6").valore,
0,
"evento senza ora: la sera prima (Roma) non conta",
);
// Ora legale: il 20/09 vale CEST (UTC+2), il 20/12 CET (UTC+1): 20:30 Roma = 18:30Z / 19:30Z.
const inverno = { ...evento("iv1", "2026-12-20", "evento"), ora: "20:30" };
const ctxInverno: ContestoObiettivi = { eventi: [inverno], presenze: {}, pagelle: [] };
assert.equal(
trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:29:00Z")), "o6").valore,
0,
"inverno: 19:29Z = 20:29 a Roma, non ancora",
);
assert.equal(
trova(obiettiviSquadra(giocatori, ctxInverno, new Date("2026-12-20T19:30:00Z")), "o6").valore,
1,
"inverno: 19:30Z = 20:30 a Roma, conta",
);
// Cambio mese a mezzanotte di Roma: 30/09 23:30Z è già 01/10 a Roma (CEST).
const fineSett = { ...evento("fs1", "2026-09-30", "evento"), ora: "20:00" };
const ctxFine: ContestoObiettivi = { eventi: [fineSett], presenze: {}, pagelle: [] };
assert.equal(
trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T21:59:00Z")), "o6").valore,
1,
"30/09 23:59 a Roma: ancora settembre, conta",
);
assert.equal(
trova(obiettiviSquadra(giocatori, ctxFine, new Date("2026-09-30T22:00:00Z")), "o6").valore,
0,
"01/10 00:00 a Roma: nuovo mese, si azzera",
);
// Più eventi: ne basta uno passato; quello futuro non si somma.
const misti: ContestoObiettivi = {
eventi: [evento("m1", "2026-09-05", "evento"), evento("m2", "2026-09-28", "evento")],
presenze: {},
pagelle: [],
};
assert.equal(
trova(obiettiviSquadra(giocatori, misti, OGGI_SETTEMBRE), "o6").valore,
1,
"conta solo l'evento già passato",
);
const allenamentoNelMese: ContestoObiettivi = {
eventi: [evento("al1", "2026-08-12", "allenamento")],
presenze: {},
@@ -487,85 +245,23 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
);
}
// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) ---
{
const [g1, g2, g3, g4] = giocatori;
const rosa4 = [g1!, g2!, g3!, g4!];
const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7");
// --- somme sulla rosa --------------------------------------------------------
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
assert.equal(trova(vuoti, "o7").valore, sommaPresenze);
assert.equal(
trova(obiettiviSquadra([], contestoVuoto), "o7").valore,
0,
"rosa vuota: nessuna presenza",
);
assert.equal(o7(contestoVuoto).titolo, "Presenze collettive");
assert.equal(o7(contestoVuoto).target, 90);
assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN");
assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0");
// Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%.
const base: ContestoObiettivi = {
eventi: [evento("c1", "2026-07-01", "allenamento"), evento("c2", "2026-12-01", "partita")],
presenze: {
c1: { [g1!.id]: "presente", [g2!.id]: "ritardo", [g3!.id]: "presente", [g4!.id]: "assente" },
c2: { [g1!.id]: "presente", [g2!.id]: "assente" },
},
pagelle: [],
};
assert.equal(o7(base).valore, 50, "passati e futuri nel denominatore, ritardo conta");
// Con convocati il denominatore sono solo loro: c1 per g1 e g2 (2 presenti su 2) = 100%.
const conConvocati: ContestoObiettivi = {
...base,
eventi: [{ ...evento("c1", "2026-07-01", "allenamento"), convocati: [g1!.id, g2!.id] }],
};
assert.equal(o7(conConvocati).valore, 100, "denominatore = convocati");
// Un non convocato che risponde "presente" non gonfia la percentuale.
const nonConvocato: ContestoObiettivi = {
...conConvocati,
presenze: { c1: { [g1!.id]: "presente", [g3!.id]: "presente" } },
};
assert.equal(o7(nonConvocato).valore, 50, "i non convocati non contano (1 su 2)");
// Arrotondamento: 179 presenze su 200 posti = 89,5% -> 90; 178 su 200 = 89%.
const rosa100 = Array.from({ length: 100 }, (_, i) => ({ ...g1!, id: `r${i}` }));
const rispondi = (presenti: number) =>
Object.fromEntries(rosa100.map((g, i) => [g.id, i < presenti ? "presente" : "assente"]));
const duePartite = (a: number, b: number): ContestoObiettivi => ({
eventi: [evento("t1", "2026-07-01", "allenamento"), evento("t2", "2026-07-02", "allenamento")],
presenze: {
t1: rispondi(a) as Record<string, "presente" | "assente">,
t2: rispondi(b) as Record<string, "presente" | "assente">,
},
pagelle: [],
});
assert.equal(o7(duePartite(90, 89), rosa100).valore, 90, "89,5% arrotonda a 90");
assert.equal(o7(duePartite(90, 88), rosa100).valore, 89, "89% resta 89");
assert.equal(o7(duePartite(100, 100), rosa100).valore, 100);
// Evento privo di risposte: pesa nel denominatore ma non nel numeratore.
const senzaRisposte: ContestoObiettivi = {
eventi: [evento("n1", "2026-07-01", "allenamento")],
presenze: {},
pagelle: [],
};
assert.equal(o7(senzaRisposte).valore, 0, "nessuna risposta: 0%");
// Convocati che non sono in rosa: non entrano nei posti.
const fuoriRosa: ContestoObiettivi = {
eventi: [{ ...evento("f1", "2026-07-01", "allenamento"), convocati: ["sconosciuto"] }],
presenze: {},
pagelle: [],
};
assert.equal(o7(fuoriRosa).valore, 0, "nessun posto valido: 0, non NaN");
// Eventi sociali e compleanni non fanno parte del denominatore.
const sociali: ContestoObiettivi = {
...base,
eventi: [
...base.eventi,
evento("c3", "2026-07-10", "evento"),
evento("c4", "2026-07-11", "compleanno"),
],
};
assert.equal(o7(sociali).valore, 50, "solo allenamenti e partite");
}
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30).
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! }));
assert.equal(
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
30,
"somma di presenze note, indipendente dal roster reale",
);
const pagelle: VotoPagella[] = [
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
@@ -692,7 +388,6 @@ 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);
@@ -722,17 +417,4 @@ 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");
-6
View File
@@ -57,12 +57,6 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
titolo: "🏐 Palloni",
testo: "Sabato tocca a te.",
},
{
// Compleanno (DD-047): solo il titolo. Il corpo vuoto resta vuoto, non diventa il testo di ripiego.
data: { json: () => ({ title: "Buon compleanno, Marco! 🎂", body: "" }) },
titolo: "Buon compleanno, Marco! 🎂",
testo: "",
},
{ data: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
{
data: {