Compare commits
11
Commits
683eb87320
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d0a8c7971 | ||
|
|
29acb326a7 | ||
|
|
77e5daaa7a | ||
|
|
519f9aaff4 | ||
|
|
e7ae2c1884 | ||
|
|
c2c3b249f2 | ||
|
|
6b8cc33512 | ||
|
|
ff02b7439c | ||
|
|
985fc2fcc1 | ||
|
|
7347fdba6e | ||
|
|
008f0d784c |
No files matched your search
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 01/10/2026
|
Ultimo aggiornamento: 06/10/2026
|
||||||
|
|
||||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
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
|
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||||
@@ -9,17 +9,18 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
|||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
- **Versione:** 1.2.2 su `develop` (su `main`, in produzione, resta la 1.2.1 fino al merge) — notifiche
|
- **Versione:** 1.3.0 (tag `v1.3.0` su `main`) — notifica di compleanno, obiettivi cliccabili con card di
|
||||||
su tre canali, avvisi di palloni (3 ore prima) e presenze (24 ore prima) una sola volta, niente
|
dettaglio, presenze calcolate sui convocati, avvisi in Home ridisegnati, caratteri più grandi, tessera CSI
|
||||||
promemoria evento, niente foto tessera, avviso certificato a 30 giorni per il giocatore.
|
tolta dalla dashboard. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
||||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||||
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
|
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
|
||||||
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
|
`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
|
||||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
`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
|
- **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
|
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||||
è su `develop`, non ancora su `main`.
|
è su `main` dalla 1.3.0.
|
||||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
- **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
|
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
||||||
collega al primo accesso (DD-018).
|
collega al primo accesso (DD-018).
|
||||||
|
|||||||
@@ -85,9 +85,13 @@ quando il database non risponde.
|
|||||||
## UI
|
## UI
|
||||||
|
|
||||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
|
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
|
||||||
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||||
schermate, pochi click.
|
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
|
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
|
`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
|
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
|
||||||
|
|||||||
@@ -13,6 +13,62 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [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.
|
||||||
|
|
||||||
|
### 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
|
## [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
|
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
|
||||||
|
|||||||
+14
-12
@@ -22,6 +22,7 @@ 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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||||
@@ -58,7 +59,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
|||||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
|
| `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. |
|
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
|
||||||
| `numero_tessera`, `data_tessera` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include tra i campi bloccati per chi reclama il proprio slot. |
|
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
|
||||||
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
|
| `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. |
|
| `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. |
|
||||||
|
|
||||||
@@ -121,11 +122,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.
|
- 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
|
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||||
- Il `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||||
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
||||||
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
||||||
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
||||||
automatici ma non quelli dei pulsanti.
|
automatici (compleanni compresi) ma non quelli dei pulsanti.
|
||||||
|
|
||||||
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
||||||
|
|
||||||
@@ -151,15 +152,16 @@ 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
|
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.
|
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||||
|
|
||||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
| 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_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_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_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_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. | — |
|
| `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) |
|
||||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
| `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. | — |
|
||||||
| `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). | — |
|
| `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
|
## Badge
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -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
|
- [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
|
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
|
||||||
resta un'estensione futura
|
resta un'estensione futura
|
||||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
|
||||||
tesserato (numero e data di tessera)
|
di tessera è stato tolto dalla dashboard (DD-048)
|
||||||
- [x] Collegamento CSI (stagione 2025/26)
|
- [x] Collegamento CSI (stagione 2025/26)
|
||||||
- [x] Classifica automatica (campionato e Coppa)
|
- [x] Classifica automatica (campionato e Coppa)
|
||||||
- [x] Risultati campionato
|
- [x] Risultati campionato
|
||||||
@@ -38,15 +38,18 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
|||||||
calcolata dal CSI
|
calcolata dal CSI
|
||||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||||
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
|
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima, nero dopo la
|
||||||
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
scadenza e rosso se il certificato manca (DD-046): agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035, DD-046)
|
||||||
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
|
- [x] Notifiche email — ogni notifica in-app inviata anche per email agli account registrati,
|
||||||
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
da un worker Docker sull'host di casa via Gmail, con interruttore «Email» in Profilo
|
||||||
([specifica](modules/notifiche-email.md), DD-036)
|
([specifica](modules/notifiche-email.md), DD-036)
|
||||||
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
|
- [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)
|
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
|
## Prossimo
|
||||||
|
|
||||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
# DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
||||||
|
|
||||||
**Data:** 24 settembre 2026
|
**Data:** 24 settembre 2026
|
||||||
**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7.
|
**Stato:** Accettata (implementata, versione 1.1.0). La soglia del giocatore è stata alzata a 30 giorni da [DD-041](DD-041.md); quella dello staff resta 7. [DD-046](DD-046.md) aggiunge l'avviso per chi non ha caricato il certificato.
|
||||||
|
|
||||||
**Contesto**
|
**Contesto**
|
||||||
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
||||||
@@ -40,7 +40,8 @@ certificato viene rinnovato. Specifica completa in
|
|||||||
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
||||||
([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)).
|
([EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)).
|
||||||
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
||||||
dal widget «Completa il tuo profilo» e dalla tab Profili.
|
dal widget «Completa il tuo profilo» e dalla tab Profili. _Superata da [DD-046](DD-046.md):
|
||||||
|
anche i mancanti hanno un avviso, rosso._
|
||||||
|
|
||||||
**Conseguenze**
|
**Conseguenze**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# DD-046 — Avviso certificati: anche chi non l'ha caricato
|
||||||
|
|
||||||
|
**Data:** 4 ottobre 2026
|
||||||
|
**Stato:** Accettata (modifica [DD-035](DD-035.md): il certificato mancante non è più escluso dall'avviso)
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
L'avviso certificati di DD-035 guarda solo la scadenza e salta chi non ha mai caricato il
|
||||||
|
certificato («è un problema diverso»). Per l'admin significa che chi non ha caricato nulla non
|
||||||
|
compare in Home, pur essendo un caso più grave di uno scaduto.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
|
||||||
|
1. Un giocatore in rosa **senza data o senza file** del certificato è **mancante**.
|
||||||
|
2. L'**admin** vede la card rossa «Certificati mancanti» con l'elenco, **sopra** scaduti e in
|
||||||
|
scadenza; il suo nome non compare se è anche giocatore.
|
||||||
|
3. Il **giocatore** mancante vede la card rossa «Certificato mancante», cliccabile verso il tab
|
||||||
|
documenti. Vale anche per l'admin che è giocatore.
|
||||||
|
4. Nessuna soglia: l'avviso resta finché il certificato non è caricato. Il rosso è `destructive`,
|
||||||
|
già nel tema; giallo e nero degli altri avvisi non cambiano.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Stesso nero degli scaduti → non distingue «scaduto» da «mai caricato», che è più grave.
|
||||||
|
- Solo elenco admin → l'admin che è anche giocatore sarebbe l'unico a non vedere il proprio buco.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- `avvisiCertificati()` restituisce anche `mancanti`; `avvisiCertificatiUtente()` anche
|
||||||
|
`personaleMancante`.
|
||||||
|
- Un giocatore appena entrato in rosa compare tra i mancanti finché non carica il file.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se i giocatori nuovi riempiono l'elenco senza motivo, aggiungere un periodo di tolleranza.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# DD-048 — Via numero e data tessera dalla dashboard
|
||||||
|
|
||||||
|
**Data:** 6 ottobre 2026
|
||||||
|
**Stato:** Accettata (nessuna migration)
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
|
||||||
|
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
|
||||||
|
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
|
||||||
|
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
|
||||||
|
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
|
||||||
|
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
|
||||||
|
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
|
||||||
|
amministrativo: documento, certificato, dati personali.
|
||||||
|
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
|
||||||
|
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
|
||||||
|
già inseriti restano a database.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
|
||||||
|
già inseriti; si può fare dopo senza toccare l'interfaccia.
|
||||||
|
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
|
||||||
|
`0/N`, un'informazione falsa.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- L'admin non può più registrare né correggere la tessera dall'app.
|
||||||
|
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
|
||||||
|
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
|
||||||
|
migration e togliere i due campi dal tipo e dalla lettura.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# DD-049 — Caratteri più grandi in tutta l'app
|
||||||
|
|
||||||
|
**Data:** 6 ottobre 2026
|
||||||
|
**Stato:** Accettata (solo interfaccia, nessuna migration)
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
|
||||||
|
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
|
||||||
|
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
|
||||||
|
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
|
||||||
|
|
||||||
|
| Prima | Dopo |
|
||||||
|
| ----------------- | ----- |
|
||||||
|
| `text-xs` (12 px) | 13 px |
|
||||||
|
| 13 px | 14 px |
|
||||||
|
| 14 px | 15 px |
|
||||||
|
| `text-sm` (14 px) | 15 px |
|
||||||
|
| 15 px | 16 px |
|
||||||
|
|
||||||
|
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
|
||||||
|
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
|
||||||
|
|
||||||
|
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
|
||||||
|
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
|
||||||
|
(conteggio sulla campanella).
|
||||||
|
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
|
||||||
|
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
|
||||||
|
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
|
||||||
|
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
|
||||||
|
l'aspetto, non i testi né le regole su quando un avviso compare.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
|
||||||
|
barre a pillola, e ogni schermata andrebbe ricontrollata.
|
||||||
|
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
|
||||||
|
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
|
||||||
|
il testo andrebbe a capo o verrebbe troncato.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
|
||||||
|
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
|
||||||
|
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
|
||||||
|
titoli di card.
|
||||||
|
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
|
||||||
|
contenitori stretti, senza toccare il resto.
|
||||||
@@ -86,6 +86,10 @@ 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-038](DD-038.md) | Tre canali per ogni notifica |
|
||||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
| [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-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||||
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ nessuna schermata:
|
|||||||
| Dove | Cosa si nasconde all'allenatore |
|
| Dove | Cosa si nasconde all'allenatore |
|
||||||
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
||||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
|
||||||
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
|
| `/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`) |
|
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
|
||||||
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
|
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
|
||||||
@@ -132,6 +132,7 @@ 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 . |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
|||||||
|
|
||||||
## Quali notifiche e a chi
|
## Quali notifiche e a chi
|
||||||
|
|
||||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
**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
|
||||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
`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
|
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
|
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||||
|
|||||||
+32
-21
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
|||||||
indipendenti:
|
indipendenti:
|
||||||
|
|
||||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||||
|
|
||||||
@@ -34,19 +34,20 @@ 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
|
([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).
|
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||||
|
|
||||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
| # | 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`). | — | — | — | — |
|
| 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`). | — | — | — | — |
|
| 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ì |
|
| 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) |
|
| 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ì |
|
| 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ì |
|
| 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 |
|
| 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ì |
|
||||||
|
|
||||||
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
|
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
|
||||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||||
automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
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
|
||||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||||
|
|
||||||
@@ -63,15 +64,17 @@ 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
|
[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`.
|
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||||
|
|
||||||
| # | Notifica | Titolo | Corpo |
|
| # | 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) |
|
| 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. |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| 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.` |
|
| 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 |
|
||||||
|
|
||||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
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`,
|
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||||
@@ -103,6 +106,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
|
|||||||
- Assenze e infortuni.
|
- Assenze e infortuni.
|
||||||
- La registrazione o il collegamento di un nuovo giocatore.
|
- La registrazione o il collegamento di un nuovo giocatore.
|
||||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
- 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
|
### Comportamenti da conoscere
|
||||||
|
|
||||||
@@ -143,6 +147,12 @@ 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
|
- **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
|
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.
|
(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»)
|
- **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.
|
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||||
|
|
||||||
@@ -156,6 +166,7 @@ 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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`) |
|
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
**Stato:** implementato — mesi/scadenze dinamici, target stagionali fissi da rivedere a mano,
|
||||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`),
|
||||||
|
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -37,22 +38,27 @@ corrente dinamico (vedi sotto).
|
|||||||
|
|
||||||
## Obiettivi definiti
|
## Obiettivi definiti
|
||||||
|
|
||||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
|
||||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
|
||||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
| `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()` |
|
| `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` |
|
| `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` |
|
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
| `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) |
|
| `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 |
|
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | 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` |
|
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
|
||||||
|
|
||||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
primo obiettivo non completato).
|
||||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
|
||||||
|
badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
|
||||||
|
dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
|
||||||
|
`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
|
||||||
|
`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
|
||||||
|
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
|
||||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
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"
|
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.
|
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||||
@@ -65,7 +71,22 @@ 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
|
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
|
(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)
|
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||||
seguono di conseguenza.
|
seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
|
||||||
|
ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono
|
||||||
|
presenze perse.
|
||||||
|
|
||||||
|
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
|
||||||
|
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
|
||||||
|
`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`.
|
||||||
|
|
||||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
`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 è
|
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||||
@@ -144,21 +165,29 @@ 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
|
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).
|
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||||
|
|
||||||
| Obiettivi | Unit test | Integration test |
|
| Obiettivi | Unit test | Integration test |
|
||||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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`/
|
- **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
|
`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,
|
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
|
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||||
(evento dentro/fuori mese), scadenza dinamica.
|
(evento dentro/fuori mese), scadenza dinamica.
|
||||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
|
||||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
|
||||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
`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
|
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ staff.
|
|||||||
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
|
| Giocatore | `GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30` |
|
||||||
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
|
| Admin | `GIORNI_AVVISO_CERTIFICATO = 7` |
|
||||||
|
|
||||||
| Condizione | Stato | Avviso |
|
| Condizione | Stato | Avviso |
|
||||||
| --------------------- | ----------- | ------------------ |
|
| --------------------- | ----------- | ------------------------------ |
|
||||||
| `giorni > soglia` | valido | nessuno |
|
| `giorni > soglia` | valido | nessuno |
|
||||||
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
|
| `0 ≤ giorni ≤ soglia` | in scadenza | giallo (`warning`) |
|
||||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||||
| data o file mancanti | mancante | nessuno |
|
| data o file mancanti | mancante | rosso (`destructive`) (DD-046) |
|
||||||
|
|
||||||
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
|
Esempio: scadenza il 5 ottobre 2026. Dal 5 settembre l'avviso giallo compare solo al
|
||||||
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
|
giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi.
|
||||||
@@ -130,8 +130,10 @@ giocatore; dal 28 settembre anche agli admin; dal 6 ottobre è nero per entrambi
|
|||||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||||
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
- Il certificato **mancante** (nessuna data o nessun file) ha un avviso rosso, più grave dello
|
||||||
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
scaduto (DD-046): il giocatore vede «Certificato mancante» (cliccabile, porta ai documenti),
|
||||||
|
l'admin la card «Certificati mancanti» con l'elenco, in cima alle altre, senza il proprio
|
||||||
|
nome. Non ha soglie: resta finché il file non è caricato.
|
||||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||||
cioè `attivo` e `tipo = 'giocatore'`).
|
cioè `attivo` e `tipo = 'giocatore'`).
|
||||||
|
|
||||||
@@ -190,16 +192,16 @@ la Home non deve rompersi per un dato accessorio.
|
|||||||
|
|
||||||
#### Implementazione
|
#### Implementazione
|
||||||
|
|
||||||
| Pezzo | Ruolo |
|
| Pezzo | Ruolo |
|
||||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
|
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia dello staff, 7 |
|
||||||
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
|
| `GIORNI_AVVISO_CERTIFICATO_GIOCATORE` | la soglia del giocatore, 30 (DD-041) |
|
||||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||||
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
| `avvisiCertificati(rosa, profili, oggi, soglia)` | funzione pura (`soglia` di default 7): restituisce `{ scaduti, inScadenza, mancanti }`; le prime due sono liste ordinate di `{ giocatoreId, nome, cognome, scadenza, giorni }`, `mancanti` di `{ giocatoreId, nome, cognome }` (DD-046) |
|
||||||
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
|
| `avvisiCertificatiUtente(rosa, profili, oggi, utente)` | funzione pura: `{ personale, personaleMancante, staff }` per chi guarda; l'admin giocatore ha entrambi, senza il suo nome nello staff (DD-041) |
|
||||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||||
|
|
||||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||||
@@ -326,7 +328,6 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
|
|||||||
- Stato del profilo
|
- Stato del profilo
|
||||||
- Certificato medico
|
- Certificato medico
|
||||||
- Documento di identità
|
- Documento di identità
|
||||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
|
||||||
|
|
||||||
Azioni disponibili.
|
Azioni disponibili.
|
||||||
|
|
||||||
@@ -334,7 +335,6 @@ Azioni disponibili.
|
|||||||
- Scarica certificato
|
- Scarica certificato
|
||||||
- Scarica documento
|
- Scarica documento
|
||||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
- 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
|
- Scollega account, per liberare uno slot assegnato per errore
|
||||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||||
@@ -360,12 +360,9 @@ Campi esportati.
|
|||||||
|
|
||||||
## Tracciamento tesseramento
|
## Tracciamento tesseramento
|
||||||
|
|
||||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
|
||||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
|
||||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
|
||||||
(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
|
## Completamento profilo
|
||||||
|
|
||||||
|
|||||||
@@ -48,20 +48,19 @@ gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
|||||||
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
|
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,
|
«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
|
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
|
||||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
|
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche»). La
|
||||||
specifica completa sta in [allenatore.md](allenatore.md).
|
specifica completa sta in [allenatore.md](allenatore.md).
|
||||||
|
|
||||||
## Gestione dati squadra (solo amministratore)
|
## Gestione dati squadra (solo amministratore)
|
||||||
|
|
||||||
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)):
|
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)):
|
||||||
|
|
||||||
| Azione | Hook | Effetto |
|
| Azione | Hook | Effetto |
|
||||||
| ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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
|
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||||
accesso con Google, per corrispondenza email
|
accesso con Google, per corrispondenza email
|
||||||
|
|||||||
Generated
+249
-2046
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"@tanstack/react-query": "^5.101.1",
|
"@tanstack/react-query": "^5.104.1",
|
||||||
"@tanstack/react-router": "^1.170.18",
|
"@tanstack/react-router": "^1.170.41",
|
||||||
"@tanstack/react-start": "^1.168.32",
|
"@tanstack/react-start": "^1.168.60",
|
||||||
"@tanstack/router-plugin": "^1.168.23",
|
"@tanstack/router-plugin": "^1.168.42",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
|
||||||
|
* sua e un titolo, così si distingue anche senza vedere i colori.
|
||||||
|
*/
|
||||||
|
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
|
||||||
|
|
||||||
|
const toni: Record<TonoAvviso, string> = {
|
||||||
|
critico: "bg-destructive text-destructive-foreground",
|
||||||
|
attenzione: "bg-warning text-warning-foreground",
|
||||||
|
scaduto: "bg-primary text-primary-foreground",
|
||||||
|
azione: "bg-accent-grad text-accent-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
|
||||||
|
export type RigaAvviso = { chi: string; cosa: string };
|
||||||
|
|
||||||
|
type ProprietaCorpo = {
|
||||||
|
tono: TonoAvviso;
|
||||||
|
icona: LucideIcon;
|
||||||
|
titolo: string;
|
||||||
|
/** Frasi di spiegazione, una per paragrafo. */
|
||||||
|
testi?: string[];
|
||||||
|
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
|
||||||
|
elenco?: RigaAvviso[];
|
||||||
|
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
|
||||||
|
cliccabile?: boolean;
|
||||||
|
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
|
||||||
|
etichetta?: string;
|
||||||
|
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
|
||||||
|
riquadri?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
|
||||||
|
export function CorpoAvviso({
|
||||||
|
tono,
|
||||||
|
icona: Icona,
|
||||||
|
titolo,
|
||||||
|
testi = [],
|
||||||
|
elenco = [],
|
||||||
|
cliccabile = false,
|
||||||
|
etichetta,
|
||||||
|
riquadri = false,
|
||||||
|
}: ProprietaCorpo) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
|
||||||
|
>
|
||||||
|
<Icona className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
|
||||||
|
{titolo}
|
||||||
|
</p>
|
||||||
|
{etichetta ? (
|
||||||
|
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
|
||||||
|
{etichetta}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{elenco.length > 1 ? (
|
||||||
|
<span
|
||||||
|
aria-label={`${elenco.length} persone`}
|
||||||
|
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
|
||||||
|
>
|
||||||
|
{elenco.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testi.map((t) => (
|
||||||
|
<p
|
||||||
|
key={t}
|
||||||
|
className={cn(
|
||||||
|
"mt-3 text-[15px] leading-snug",
|
||||||
|
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{elenco.length > 0 ? (
|
||||||
|
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
|
||||||
|
{elenco.map((r) => (
|
||||||
|
<li
|
||||||
|
key={`${r.chi}-${r.cosa}`}
|
||||||
|
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
|
||||||
|
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
|
||||||
|
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
|
||||||
|
*/
|
||||||
|
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
|
||||||
|
const card = <CorpoAvviso {...corpo} />;
|
||||||
|
return (
|
||||||
|
<Reveal indice={indice} className="mx-5 mt-4">
|
||||||
|
{corpo.cliccabile ? (
|
||||||
|
<Link
|
||||||
|
to="/profilo"
|
||||||
|
search={{ tab: "documenti" }}
|
||||||
|
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||||
|
>
|
||||||
|
{card}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div role="status">{card}</div>
|
||||||
|
)}
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AlertCircle } from "lucide-react";
|
import { CalendarClock, FileClock, FileX } from "lucide-react";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
|
||||||
import { useProfili } from "@/lib/profili";
|
import { useProfili } from "@/lib/profili";
|
||||||
import {
|
import {
|
||||||
avvisiCertificatiUtente,
|
avvisiCertificatiUtente,
|
||||||
@@ -12,45 +12,8 @@ import { oggiISO } from "@/lib/palloni-core";
|
|||||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||||
import { useGiocatoreBase } from "@/lib/user-store";
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
function Card({
|
|
||||||
colore,
|
|
||||||
titolo,
|
|
||||||
righe,
|
|
||||||
cliccabile,
|
|
||||||
}: {
|
|
||||||
colore: "warning" | "primary";
|
|
||||||
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"
|
|
||||||
: "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 in scadenza/scaduti (DD-035, DD-041): calcolato al volo, niente push.
|
* 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.
|
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
|
||||||
*/
|
*/
|
||||||
export function AvvisoCertificati() {
|
export function AvvisoCertificati() {
|
||||||
@@ -65,50 +28,73 @@ export function AvvisoCertificati() {
|
|||||||
const oggi = oggiISO();
|
const oggi = oggiISO();
|
||||||
|
|
||||||
// Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
|
// Admin che è anche giocatore: vede il proprio avviso a 30 giorni e quello dello staff (DD-041).
|
||||||
const { personale, staff } = avvisiCertificatiUtente(rosa, profili, oggi, { admin, base });
|
const { personale, personaleMancante, staff } = avvisiCertificatiUtente(rosa, profili, oggi, {
|
||||||
const cardPersonale = personale ? (
|
admin,
|
||||||
|
base,
|
||||||
|
});
|
||||||
|
const cardPersonale = personaleMancante ? (
|
||||||
|
<Avviso
|
||||||
|
tono="critico"
|
||||||
|
icona={FileX}
|
||||||
|
titolo="Certificato mancante"
|
||||||
|
cliccabile
|
||||||
|
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
||||||
|
/>
|
||||||
|
) : personale ? (
|
||||||
personale.giorni < 0 ? (
|
personale.giorni < 0 ? (
|
||||||
<Card
|
<Avviso
|
||||||
colore="primary"
|
tono="scaduto"
|
||||||
|
icona={FileClock}
|
||||||
titolo="Certificato scaduto"
|
titolo="Certificato scaduto"
|
||||||
cliccabile
|
cliccabile
|
||||||
righe={[
|
testi={[
|
||||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Card
|
<Avviso
|
||||||
colore="warning"
|
tono="attenzione"
|
||||||
|
icona={CalendarClock}
|
||||||
titolo="Certificato in scadenza"
|
titolo="Certificato in scadenza"
|
||||||
cliccabile
|
cliccabile
|
||||||
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!staff) return cardPersonale;
|
if (!staff) return cardPersonale;
|
||||||
const { scaduti, inScadenza } = staff;
|
const { scaduti, inScadenza, mancanti } = staff;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{cardPersonale}
|
{cardPersonale}
|
||||||
|
{mancanti.length > 0 ? (
|
||||||
|
<Avviso
|
||||||
|
tono="critico"
|
||||||
|
icona={FileX}
|
||||||
|
titolo="Certificati mancanti"
|
||||||
|
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{scaduti.length > 0 ? (
|
{scaduti.length > 0 ? (
|
||||||
<Card
|
<Avviso
|
||||||
colore="primary"
|
tono="scaduto"
|
||||||
|
icona={FileClock}
|
||||||
titolo="Certificati scaduti"
|
titolo="Certificati scaduti"
|
||||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{inScadenza.length > 0 ? (
|
{inScadenza.length > 0 ? (
|
||||||
<Card
|
<Avviso
|
||||||
colore="warning"
|
tono="attenzione"
|
||||||
|
icona={CalendarClock}
|
||||||
titolo="Certificati in scadenza"
|
titolo="Certificati in scadenza"
|
||||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
|
||||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
|
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
|
||||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||||
{def.descrizione}
|
{def.descrizione}
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,13 +42,15 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
{stato ? (
|
{stato ? (
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Stato attuale
|
Stato attuale
|
||||||
</p>
|
</p>
|
||||||
{grado ? (
|
{grado ? (
|
||||||
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
|
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
|
||||||
|
{meta?.label}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-bold uppercase text-muted-foreground">
|
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
||||||
In progresso
|
In progresso
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -59,19 +61,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
{stato.prossimaSoglia ? (
|
{stato.prossimaSoglia ? (
|
||||||
<>
|
<>
|
||||||
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
|
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
|
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
|
||||||
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
|
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
|
<p className="mt-2 text-[13px] text-success">Hai raggiunto il livello massimo.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Soglie
|
Soglie
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -88,7 +90,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs font-bold uppercase",
|
"text-[13px] font-bold uppercase",
|
||||||
raggiunto ? gm.text : "text-muted-foreground",
|
raggiunto ? gm.text : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -102,7 +104,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
>
|
>
|
||||||
{def.soglie[g]}
|
{def.soglie[g]}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{def.unita}</p>
|
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -110,7 +112,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{def.celebrazione ? (
|
{def.celebrazione ? (
|
||||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
|
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
||||||
“{def.celebrazione}”
|
“{def.celebrazione}”
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -142,7 +144,7 @@ export function BadgeDrawer({
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
Indietro
|
Indietro
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ export function CelebrazioneBadge() {
|
|||||||
{notifica.emoji}
|
{notifica.emoji}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||||
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
|
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
|
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
|
||||||
<p className="mt-1.5 text-sm text-muted-foreground">{notifica.testo}</p>
|
<p className="mt-1.5 text-[15px] text-muted-foreground">{notifica.testo}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,7 +69,7 @@ export function CelebrazioneBadge() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={chiudi}
|
onClick={chiudi}
|
||||||
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
|
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-[15px] font-bold uppercase tracking-wide text-accent-foreground"
|
||||||
>
|
>
|
||||||
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
|
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
|
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
|
||||||
{meta ? (
|
{meta ? (
|
||||||
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
|
<span className={cn("text-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{b.valore} {b.def.unita}
|
{b.valore} {b.def.unita}
|
||||||
</p>
|
</p>
|
||||||
{b.prossimaSoglia ? (
|
{b.prossimaSoglia ? (
|
||||||
<>
|
<>
|
||||||
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
|
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
|
||||||
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
|
<p className="mt-1 text-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||||
)}
|
)}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
@@ -90,13 +90,15 @@ function SocialDrawer({
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
|
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
|
||||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||||
{cat.descrizione}
|
{cat.descrizione}
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Vinto
|
||||||
|
</p>
|
||||||
<p className="mt-1 font-display text-3xl leading-none">
|
<p className="mt-1 font-display text-3xl leading-none">
|
||||||
{conteggio}{" "}
|
{conteggio}{" "}
|
||||||
<span className="text-lg text-muted-foreground">
|
<span className="text-lg text-muted-foreground">
|
||||||
@@ -109,7 +111,7 @@ function SocialDrawer({
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
Indietro
|
Indietro
|
||||||
</button>
|
</button>
|
||||||
@@ -139,7 +141,7 @@ export function CollezioneBadge({
|
|||||||
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||||
<div className="flex items-end justify-between gap-3">
|
<div className="flex items-end justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||||
Collezione badge
|
Collezione badge
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-4xl leading-none">
|
<p className="font-display text-4xl leading-none">
|
||||||
@@ -147,7 +149,7 @@ export function CollezioneBadge({
|
|||||||
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-primary-foreground/70">
|
<p className="text-[13px] text-primary-foreground/70">
|
||||||
<Numero valore={pct} suffisso="%" /> completata
|
<Numero valore={pct} suffisso="%" /> completata
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,12 +158,12 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{vicino ? (
|
{vicino ? (
|
||||||
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
|
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||||
Prossimo traguardo
|
Prossimo traguardo
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
|
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
|
||||||
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +171,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{c.sbloccati.length > 0 ? (
|
{c.sbloccati.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Sbloccati ({c.sbloccati.length})
|
Sbloccati ({c.sbloccati.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -184,7 +186,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{c.inProgresso.length > 0 ? (
|
{c.inProgresso.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
In progresso ({c.inProgresso.length})
|
In progresso ({c.inProgresso.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -199,7 +201,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{socialVinti.length > 0 ? (
|
{socialVinti.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Votati dai compagni
|
Votati dai compagni
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -207,8 +209,8 @@ export function CollezioneBadge({
|
|||||||
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
|
<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">
|
<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="text-lg leading-none">{cat.emoji}</p>
|
||||||
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
|
<p className="mt-1 text-[15px] font-bold leading-tight">{cat.nome}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
|
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +221,7 @@ export function CollezioneBadge({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Badge segreti
|
Badge segreti
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -229,8 +231,8 @@ export function CollezioneBadge({
|
|||||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
<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">
|
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-oro/40">
|
||||||
<Icon className="h-6 w-6 text-oro" />
|
<Icon className="h-6 w-6 text-oro" />
|
||||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,8 +245,10 @@ export function CollezioneBadge({
|
|||||||
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
|
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
|
||||||
>
|
>
|
||||||
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
|
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
|
||||||
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
|
<p className="mt-1.5 text-[15px] font-bold leading-tight text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
|
???
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function MetaPartitaCsi({
|
|||||||
.join(" · ");
|
.join(" · ");
|
||||||
if (!voci && !link) return null;
|
if (!voci && !link) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
|
||||||
{voci ? <span>{voci}</span> : null}
|
{voci ? <span>{voci}</span> : null}
|
||||||
{link ? (
|
{link ? (
|
||||||
<a
|
<a
|
||||||
@@ -68,17 +68,17 @@ export function MetaPartitaCsi({
|
|||||||
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{squadra.squadra}
|
{squadra.squadra}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{squadra.titolari.map((g, i) => (
|
{squadra.titolari.map((g, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<div key={i} className="flex items-center gap-2 text-[15px]">
|
||||||
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
|
||||||
{g.numero}
|
{g.numero}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||||
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-1 space-y-1">
|
<div className="mt-1 space-y-1">
|
||||||
{squadra.panchina.map((g, i) => (
|
{squadra.panchina.map((g, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div key={i} className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{squadra.staff.length > 0 ? (
|
{squadra.staff.length > 0 ? (
|
||||||
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
<div className="mt-3 space-y-0.5 text-[13px] text-muted-foreground">
|
||||||
{squadra.staff.map((s, i) => (
|
{squadra.staff.map((s, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
{s.nome} · {s.ruolo}
|
{s.nome} · {s.ruolo}
|
||||||
@@ -121,7 +121,7 @@ function BarraPrecedenti({
|
|||||||
}) {
|
}) {
|
||||||
const totale = noi + avversario || 1;
|
const totale = noi + avversario || 1;
|
||||||
return (
|
return (
|
||||||
<div className="text-xs">
|
<div className="text-[13px]">
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
<span className="font-bold text-foreground">{noi}</span>
|
<span className="font-bold text-foreground">{noi}</span>
|
||||||
<span>{label}</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. */
|
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
|
||||||
function TestataSquadre({ avversario }: { avversario: string }) {
|
function TestataSquadre({ avversario }: { avversario: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-xs font-bold">
|
<div className="flex items-center justify-between text-[13px] font-bold">
|
||||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||||
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +158,7 @@ function BarraProbabilita({
|
|||||||
const favoritaNoi = noi >= avversarioPct;
|
const favoritaNoi = noi >= avversarioPct;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs text-muted-foreground">
|
<p className="mb-2 text-[13px] text-muted-foreground">
|
||||||
Probabilità di vittoria (calcolo CSI):{" "}
|
Probabilità di vittoria (calcolo CSI):{" "}
|
||||||
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||||
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||||
@@ -171,13 +171,13 @@ function BarraProbabilita({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-7 overflow-hidden rounded-full">
|
<div className="flex h-7 overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
className="flex items-center justify-center bg-success text-[13px] font-bold text-success-foreground"
|
||||||
style={{ width: `${noi}%` }}
|
style={{ width: `${noi}%` }}
|
||||||
>
|
>
|
||||||
{noi.toFixed(0)}%
|
{noi.toFixed(0)}%
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
|
||||||
style={{ width: `${avversarioPct}%` }}
|
style={{ width: `${avversarioPct}%` }}
|
||||||
>
|
>
|
||||||
{avversarioPct.toFixed(0)}%
|
{avversarioPct.toFixed(0)}%
|
||||||
@@ -197,7 +197,7 @@ function BloccoPrecedenti({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<TestataSquadre avversario={avversario} />
|
<TestataSquadre avversario={avversario} />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{precedenti.totale > 0
|
{precedenti.totale > 0
|
||||||
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||||
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||||
@@ -248,20 +248,22 @@ export function DettaglioCsiEsteso({
|
|||||||
const { data, isLoading } = useCsiPartita(matchId);
|
const { data, isLoading } = useCsiPartita(matchId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
return (
|
||||||
|
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.giornata || data.nota ? (
|
{data.giornata || data.nota ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{data.formazioni ? (
|
{data.formazioni ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@@ -272,7 +274,7 @@ export function DettaglioCsiEsteso({
|
|||||||
) : null}
|
) : null}
|
||||||
{data.precedenti ? (
|
{data.precedenti ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||||
</div>
|
</div>
|
||||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||||
|
|||||||
@@ -101,10 +101,10 @@ export function EventoCard({
|
|||||||
<Cake className="h-5 w-5" />
|
<Cake className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
|
<h3 className="truncate text-[15px] font-bold leading-tight">{evento.titolo}</h3>
|
||||||
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
|
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{etichettaData(evento.data)}
|
{etichettaData(evento.data)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -139,13 +139,13 @@ export function EventoCard({
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
"inline-block rounded-full px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
||||||
tipo.className,
|
tipo.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tipo.label}
|
{tipo.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{etichettaData(evento.data)}
|
{etichettaData(evento.data)}
|
||||||
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
|
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
|
||||||
</span>
|
</span>
|
||||||
@@ -153,10 +153,10 @@ export function EventoCard({
|
|||||||
|
|
||||||
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
||||||
{sottotitoloPartita ? (
|
{sottotitoloPartita ? (
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
|
<p className="mt-0.5 text-[13px] text-muted-foreground">{sottotitoloPartita}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
|
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
<span>{evento.ora}</span>
|
<span>{evento.ora}</span>
|
||||||
@@ -174,12 +174,12 @@ export function EventoCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{noteCard ? (
|
{noteCard ? (
|
||||||
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
|
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{metaStato ? (
|
{metaStato ? (
|
||||||
<p
|
<p
|
||||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
|
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{metaStato.emoji}</span>
|
<span aria-hidden>{metaStato.emoji}</span>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
|
||||||
|
import { Barra } from "@/components/motion/Barra";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerTrigger,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
|
function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{titolo}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{voci.map((v) => (
|
||||||
|
<li key={v} className="flex gap-2 text-[15px] leading-snug">
|
||||||
|
<span aria-hidden className="shrink-0 font-bold text-accent">
|
||||||
|
{segno}
|
||||||
|
</span>
|
||||||
|
<span>{v}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Blocco({ titolo, testo }: { titolo: string; testo: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{titolo}
|
||||||
|
</p>
|
||||||
|
<p className="text-[15px] leading-relaxed">{testo}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
|
||||||
|
export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
|
||||||
|
const pct = progressoObiettivo(o);
|
||||||
|
const fatto = pct >= 100;
|
||||||
|
const d = o.dettaglio;
|
||||||
|
return (
|
||||||
|
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 pt-0">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-secondary text-2xl ring-1 ring-border">
|
||||||
|
{o.emoji}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<DrawerTitle className="text-xl font-bold leading-tight">{o.titolo}</DrawerTitle>
|
||||||
|
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||||
|
{o.descrizione}
|
||||||
|
</DrawerDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Stato attuale
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[13px] font-bold uppercase",
|
||||||
|
fatto ? "text-success" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fatto ? "Completato" : "In corso"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-display text-3xl leading-none">
|
||||||
|
{o.valore} <span className="text-lg text-muted-foreground">su {o.target}</span>{" "}
|
||||||
|
<span className="text-lg text-muted-foreground">{o.unita}</span>
|
||||||
|
</p>
|
||||||
|
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||||
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
|
{pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Blocco titolo="Come si calcola" testo={d.comeSiCalcola} />
|
||||||
|
<Elenco titolo="Cosa conta" voci={d.conta} segno="+" />
|
||||||
|
{d.perse ? <Elenco titolo="Presenza persa" voci={d.perse} segno="−" /> : null}
|
||||||
|
<Elenco
|
||||||
|
titolo={d.perse ? "Non entra nel calcolo" : "Cosa non conta"}
|
||||||
|
voci={d.nonConta}
|
||||||
|
segno={d.perse ? "·" : "−"}
|
||||||
|
/>
|
||||||
|
<Blocco titolo="Quando vale" testo={d.periodo} />
|
||||||
|
<Blocco titolo="Da dove arrivano i dati" testo={d.fonte} />
|
||||||
|
<Blocco titolo="Un esempio" testo={d.esempio} />
|
||||||
|
|
||||||
|
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
||||||
|
{o.impatto}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
|
||||||
|
export function ObiettivoDrawer({
|
||||||
|
obiettivo,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
obiettivo: ObiettivoSquadra;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader className="sr-only">
|
||||||
|
<DrawerTitle>{obiettivo.titolo}</DrawerTitle>
|
||||||
|
<DrawerDescription>{obiettivo.descrizione}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<SchedaObiettivo o={obiettivo} />
|
||||||
|
<DrawerFooter>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
Indietro
|
||||||
|
</button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,19 +45,19 @@ export function Pagelle({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
||||||
</p>
|
</p>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||||
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
|
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
|
<p aria-busy="true" className="mt-3 text-[13px] text-muted-foreground">
|
||||||
Carico le pagelle…
|
Carico le pagelle…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -70,15 +70,17 @@ export function Pagelle({
|
|||||||
return (
|
return (
|
||||||
<div key={g.id} className="rounded-2xl bg-secondary/60 p-2.5">
|
<div key={g.id} className="rounded-2xl bg-secondary/60 p-2.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||||
<span className="text-xs text-muted-foreground">
|
{g.nome}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] text-muted-foreground">
|
||||||
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
|
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{sonoIo ? (
|
{sonoIo ? (
|
||||||
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
<span className="shrink-0 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||||
Sei tu
|
Sei tu
|
||||||
</span>
|
</span>
|
||||||
) : chiuse ? (
|
) : chiuse ? (
|
||||||
@@ -89,7 +91,7 @@ export function Pagelle({
|
|||||||
disabled={!io || vota.isPending}
|
disabled={!io || vota.isPending}
|
||||||
onClick={() => setApertoPer(aperto ? null : g.id)}
|
onClick={() => setApertoPer(aperto ? null : g.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
|
"shrink-0 rounded-full px-3 py-1 text-[13px] font-bold uppercase transition-colors disabled:opacity-50",
|
||||||
mio !== undefined
|
mio !== undefined
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-card text-foreground",
|
: "bg-card text-foreground",
|
||||||
@@ -107,7 +109,7 @@ export function Pagelle({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => invia(g.id, v)}
|
onClick={() => invia(g.id, v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
|
"rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90",
|
||||||
mio === v
|
mio === v
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-card text-foreground",
|
: "bg-card text-foreground",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
|
|||||||
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
<h3 className="font-display text-[15px] uppercase tracking-wide">{titolo}</h3>
|
||||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -67,7 +67,7 @@ function CampoFile({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 py-2">
|
<div className="flex items-center justify-between gap-3 py-2">
|
||||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
<span className="flex min-w-0 items-center gap-2 text-[15px]">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
||||||
@@ -93,7 +93,7 @@ function CampoFile({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => input.current?.click()}
|
onClick={() => input.current?.click()}
|
||||||
disabled={inCorso}
|
disabled={inCorso}
|
||||||
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
className="premi rounded-xl bg-primary px-3 py-2 text-[13px] font-bold text-primary-foreground disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
||||||
</button>
|
</button>
|
||||||
@@ -336,7 +336,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
|||||||
style={{ width: `${perc}%` }}
|
style={{ width: `${perc}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||||
{perc}%
|
{perc}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -366,7 +366,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void salva()}
|
onClick={() => void salva()}
|
||||||
disabled={!sporco || inCorso}
|
disabled={!sporco || inCorso}
|
||||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
{sporco ? "Salva" : "Salvato"}
|
{sporco ? "Salva" : "Salvato"}
|
||||||
@@ -430,7 +430,7 @@ export function ProfiloAmministrativo({
|
|||||||
style={{ width: `${perc}%` }}
|
style={{ width: `${perc}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||||
{perc}%
|
{perc}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,7 +472,7 @@ export function ProfiloAmministrativo({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={salvaBozza}
|
onClick={salvaBozza}
|
||||||
disabled={!sporco || salva.isPending}
|
disabled={!sporco || salva.isPending}
|
||||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
{sporco ? "Salva" : "Salvato"}
|
{sporco ? "Salva" : "Salvato"}
|
||||||
@@ -486,7 +486,9 @@ export function ProfiloAmministrativo({
|
|||||||
<SezioneTendina
|
<SezioneTendina
|
||||||
titolo="Dati per il tesseramento"
|
titolo="Dati per il tesseramento"
|
||||||
indice={indice}
|
indice={indice}
|
||||||
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
azione={
|
||||||
|
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{corpo}
|
{corpo}
|
||||||
</SezioneTendina>
|
</SezioneTendina>
|
||||||
@@ -521,10 +523,10 @@ export function CompletaProfilo({
|
|||||||
className="premi block rounded-3xl bg-card p-4 shadow-card"
|
className="premi block rounded-3xl bg-card p-4 shadow-card"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="font-display text-[15px] uppercase tracking-wide">
|
<span className="font-display text-[16px] uppercase tracking-wide">
|
||||||
Completa il tuo profilo
|
Completa il tuo profilo
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||||
<div
|
<div
|
||||||
@@ -532,7 +534,7 @@ export function CompletaProfilo({
|
|||||||
style={{ width: `${perc}%` }}
|
style={{ width: `${perc}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
<p className="mt-2 text-[14px] text-muted-foreground">
|
||||||
{allenatore
|
{allenatore
|
||||||
? "Data e luogo di nascita, telefono ed email."
|
? "Data e luogo di nascita, telefono ed email."
|
||||||
: "Documento e certificato medico servono per il tesseramento CSI."}
|
: "Documento e certificato medico servono per il tesseramento CSI."}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertCircle } from "lucide-react";
|
import { Volleyball } from "lucide-react";
|
||||||
|
import { Avviso } from "@/components/crapp/Avviso";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||||
import { useTurniPalloni } from "@/lib/palloni";
|
import { useTurniPalloni } from "@/lib/palloni";
|
||||||
@@ -17,6 +18,8 @@ export function PromemoriaPalloni() {
|
|||||||
|
|
||||||
const oggi = oggiISO();
|
const oggi = oggiISO();
|
||||||
const messaggi: string[] = [];
|
const messaggi: string[] = [];
|
||||||
|
// «Oggi» solo se c'è davvero qualcosa da fare oggi; altrimenti è un semplice promemoria.
|
||||||
|
let oggiTocca = true;
|
||||||
|
|
||||||
for (const evento of lista) {
|
for (const evento of lista) {
|
||||||
if (evento.data !== oggi) continue;
|
if (evento.data !== oggi) continue;
|
||||||
@@ -36,6 +39,7 @@ export function PromemoriaPalloni() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (messaggi.length === 0) {
|
if (messaggi.length === 0) {
|
||||||
|
oggiTocca = false;
|
||||||
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
|
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
|
||||||
if (!prossimo) return null;
|
if (!prossimo) return null;
|
||||||
messaggi.push(
|
messaggi.push(
|
||||||
@@ -44,15 +48,13 @@ export function PromemoriaPalloni() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
|
<Avviso
|
||||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
tono="azione"
|
||||||
<AlertCircle className="h-5 w-5" /> Turno palloni
|
icona={Volleyball}
|
||||||
</p>
|
titolo="Turno palloni"
|
||||||
{messaggi.map((m) => (
|
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
|
||||||
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
|
testi={messaggi}
|
||||||
{m}
|
riquadri
|
||||||
</p>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ export function RosaPresenze({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<p className="text-sm font-bold">
|
<p className="text-[15px] font-bold">
|
||||||
Hanno risposto {risposteN}/{rosa.length}
|
Hanno risposto {risposteN}/{rosa.length}
|
||||||
</p>
|
</p>
|
||||||
<span className="font-display text-xl leading-none">{perc}%</span>
|
<span className="font-display text-xl leading-none">{perc}%</span>
|
||||||
@@ -84,7 +84,7 @@ export function RosaPresenze({
|
|||||||
<span
|
<span
|
||||||
key={s}
|
key={s}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-2.5 py-1 text-xs font-bold",
|
"rounded-full px-2.5 py-1 text-[13px] font-bold",
|
||||||
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -92,14 +92,14 @@ export function RosaPresenze({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
|
||||||
❔ {mancanti.length} da rispondere
|
❔ {mancanti.length} da rispondere
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{io ? (
|
{io ? (
|
||||||
<div className="mt-4 border-t border-border pt-3">
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
La tua risposta
|
La tua risposta
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<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 })
|
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
|
"rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||||
attivo
|
attivo
|
||||||
? cn(statoMeta[s].className, "border-transparent shadow-card")
|
? cn(statoMeta[s].className, "border-transparent shadow-card")
|
||||||
: "bg-background text-muted-foreground",
|
: "bg-background text-muted-foreground",
|
||||||
@@ -133,7 +133,7 @@ export function RosaPresenze({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={sollecita}
|
onClick={sollecita}
|
||||||
disabled={sollecito || daSollecitare === 0 || passato}
|
disabled={sollecito || daSollecitare === 0 || passato}
|
||||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sollecito ? (
|
{sollecito ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -146,7 +146,7 @@ export function RosaPresenze({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
|
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
|
||||||
Carico le risposte…
|
Carico le risposte…
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -194,16 +194,16 @@ function Gruppo({
|
|||||||
attenzione && "border border-dashed border-warning",
|
attenzione && "border border-dashed border-warning",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{titolo} · {n}
|
{titolo} · {n}
|
||||||
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
|
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{lista.map((g) => (
|
{lista.map((g) => (
|
||||||
<div key={g.id} className="flex items-center gap-3">
|
<div key={g.id} className="flex items-center gap-3">
|
||||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{g.nome}</span>
|
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{g.ruolo}</span>
|
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ScoutEntry({
|
|||||||
{occupato ? <Lock className="h-5 w-5 shrink-0" /> : <Radio className="h-5 w-5 shrink-0" />}
|
{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="min-w-0 flex-1">
|
||||||
<span className="block font-display text-lg uppercase leading-none">{titolo}</span>
|
<span className="block font-display text-lg uppercase leading-none">{titolo}</span>
|
||||||
<span className="block text-xs opacity-80">{sottotitolo}</span>
|
<span className="block text-[13px] opacity-80">{sottotitolo}</span>
|
||||||
</span>
|
</span>
|
||||||
{disponibile ? <ChevronRight className="h-5 w-5 shrink-0" /> : null}
|
{disponibile ? <ChevronRight className="h-5 w-5 shrink-0" /> : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
|||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
|
<p className="text-[15px] font-bold leading-tight">{s.def.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{s.def.descrizione}</p>
|
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||||
<Flame
|
<Flame
|
||||||
@@ -39,7 +39,7 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Barra percentuale={s.progresso} trackClassName="mt-3" />
|
<Barra percentuale={s.progresso} trackClassName="mt-3" />
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""}
|
{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""}
|
||||||
{s.messaggio}
|
{s.messaggio}
|
||||||
</p>
|
</p>
|
||||||
@@ -61,17 +61,17 @@ export function SerieHome({ g }: { g: Giocatore }) {
|
|||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-bold leading-tight">
|
<p className="text-[15px] font-bold leading-tight">
|
||||||
Serie {top.def.label.toLowerCase()}: {top.valore}
|
Serie {top.def.label.toLowerCase()}: {top.valore}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{top.messaggio}</p>
|
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
{serieGiocatore(g).map((s) => (
|
{serieGiocatore(g).map((s) => (
|
||||||
<div key={s.def.tipo} className="rounded-2xl bg-secondary p-2 text-center">
|
<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="font-display text-xl leading-none">{s.valore}</p>
|
||||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||||
{s.def.label}
|
{s.def.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ export function SondaggioCacche({
|
|||||||
if (!sondaggioAperto(dataEvento, oraEvento)) {
|
if (!sondaggioAperto(dataEvento, oraEvento)) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
💩 Sondaggio pre-partita
|
💩 Sondaggio pre-partita
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
{sondaggioTerminato(dataEvento, oraEvento)
|
{sondaggioTerminato(dataEvento, oraEvento)
|
||||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||||
@@ -96,14 +96,14 @@ export function SondaggioCacche({
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
💩 Sondaggio pre-partita
|
💩 Sondaggio pre-partita
|
||||||
</p>
|
</p>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||||
{dellaPartita.length} risposte
|
{dellaPartita.length} risposte
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
|
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ export function SondaggioCacche({
|
|||||||
disabled={!io || salva.isPending}
|
disabled={!io || salva.isPending}
|
||||||
onClick={() => rispondi(n)}
|
onClick={() => rispondi(n)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
"min-w-11 rounded-xl px-3 py-2 text-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||||
mia?.quantita === n
|
mia?.quantita === n
|
||||||
? "bg-accent text-accent-foreground shadow-pop"
|
? "bg-accent text-accent-foreground shadow-pop"
|
||||||
: "bg-secondary text-foreground",
|
: "bg-secondary text-foreground",
|
||||||
@@ -126,7 +126,7 @@ export function SondaggioCacche({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||||
Media squadra {media}
|
Media squadra {media}
|
||||||
</span>
|
</span>
|
||||||
@@ -145,7 +145,7 @@ export function SondaggioCacche({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={avvisaTutti}
|
onClick={avvisaTutti}
|
||||||
disabled={avviso}
|
disabled={avviso}
|
||||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
|
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
|
||||||
Avvisa tutti del sondaggio
|
Avvisa tutti del sondaggio
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
|||||||
<CircleDot className="h-4 w-4" />
|
<CircleDot className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="block text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Palloni
|
Palloni
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-sm font-bold leading-tight">
|
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
|
||||||
{isPending || assegna.isPending ? (
|
{isPending || assegna.isPending ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
|
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
|
||||||
{proposto && giocatore ? (
|
{proposto && giocatore ? (
|
||||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs font-bold uppercase text-muted-foreground">
|
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[13px] font-bold uppercase text-muted-foreground">
|
||||||
proposto
|
proposto
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -100,11 +100,11 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => scegli(g.id)}
|
onClick={() => scegli(g.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
|
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[15px] transition-colors",
|
||||||
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-[13px]" />
|
||||||
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
|
<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}
|
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +117,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={avvisa}
|
onClick={avvisa}
|
||||||
disabled={avviso}
|
disabled={avviso}
|
||||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
|
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-[13px] font-bold uppercase text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{avviso ? (
|
{avviso ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,20 +75,20 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAperto((v) => !v)}
|
onClick={() => setAperto((v) => !v)}
|
||||||
disabled={!puoVotare}
|
disabled={!puoVotare}
|
||||||
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
|
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{mio ? "Cambia voto" : "Vota"}
|
{mio ? "Cambia voto" : "Vota"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs">
|
<p className="mt-2 text-[13px]">
|
||||||
{testa && !pareggio ? (
|
{testa && !pareggio ? (
|
||||||
<span className="inline-flex items-center gap-1 font-bold">
|
<span className="inline-flex items-center gap-1 font-bold">
|
||||||
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
|
<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>
|
</p>
|
||||||
{mio ? (
|
{mio ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||||
) : !puoVotare ? (
|
) : !puoVotare ? (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Vota chi era presente a questa partita.
|
Vota chi era presente a questa partita.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -120,7 +120,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
disabled={vota.isPending}
|
disabled={vota.isPending}
|
||||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
onClick={() => invia(g.id, nomeCompleto(g))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||||
mio?.votato_id === g.id
|
mio?.votato_id === g.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-card text-foreground",
|
: "bg-card text-foreground",
|
||||||
@@ -133,7 +133,7 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
) : conteggio.length > 0 ? (
|
) : conteggio.length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{conteggio.map((c) => (
|
{conteggio.map((c) => (
|
||||||
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs font-semibold">
|
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-[13px] font-semibold">
|
||||||
{c.nome} · {c.voti}
|
{c.nome} · {c.voti}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs font-semibold text-muted-foreground">
|
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-[13px] font-semibold text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
||||||
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria
|
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria
|
||||||
@@ -79,14 +79,16 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
|||||||
{cat.emoji}
|
{cat.emoji}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
|
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||||
<span className="block truncate text-xs text-muted-foreground">
|
{cat.nome}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[13px] text-muted-foreground">
|
||||||
{mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione}
|
{mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
"shrink-0 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||||
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -105,7 +107,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
|||||||
disabled={vota.isPending}
|
disabled={vota.isPending}
|
||||||
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
|
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||||
mio?.votato_id === g.id
|
mio?.votato_id === g.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-foreground",
|
: "bg-secondary text-foreground",
|
||||||
@@ -118,17 +120,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border-t border-border px-3 py-2">
|
<div className="border-t border-border px-3 py-2">
|
||||||
{totale === 0 ? (
|
{totale === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
Nessun voto ancora: apri e scegli un compagno.
|
Nessun voto ancora: apri e scegli un compagno.
|
||||||
</p>
|
</p>
|
||||||
) : vincitore ? (
|
) : vincitore ? (
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold">
|
||||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
<Crown className="h-3.5 w-3.5 text-oro" />
|
||||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||||
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
Parità con {totale} voti: servono altri voti per assegnare il badge.
|
Parità con {totale} voti: servono altri voti per assegnare il badge.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -107,13 +107,13 @@ function RigaNotifica({
|
|||||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||||
if (arrivo < -60) onElimina(notifica.id);
|
if (arrivo < -60) onElimina(notifica.id);
|
||||||
}}
|
}}
|
||||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
|
||||||
>
|
>
|
||||||
<p className="font-semibold">{notifica.titolo}</p>
|
<p className="font-semibold">{notifica.titolo}</p>
|
||||||
{notifica.corpo ? (
|
{notifica.corpo ? (
|
||||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -254,7 +254,7 @@ export function PageHeader({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
|
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
|
||||||
{sottotitolo ? (
|
{sottotitolo ? (
|
||||||
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
|
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{azione ?? <LinkProfilo />}
|
{azione ?? <LinkProfilo />}
|
||||||
@@ -338,7 +338,7 @@ export function SezioneTendina({
|
|||||||
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
|
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
|
||||||
*/
|
*/
|
||||||
export const classiInput =
|
export const classiInput =
|
||||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
|
* 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 }) {
|
export function Campo({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<label className="block min-w-0">
|
<label className="block min-w-0">
|
||||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 block">{children}</span>
|
<span className="mt-1 block">{children}</span>
|
||||||
@@ -378,7 +378,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||||
meta.className,
|
meta.className,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -402,10 +402,10 @@ export function StatTile({
|
|||||||
<p className="font-display text-2xl leading-none">
|
<p className="font-display text-2xl leading-none">
|
||||||
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
|
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
|
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ const DrawerDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DrawerPrimitive.Description
|
<DrawerPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-[15px] text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -222,40 +222,6 @@ export function useSalvaDatiSquadra() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
|
|
||||||
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
|
||||||
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
|
||||||
* tesserato.
|
|
||||||
*/
|
|
||||||
export function useSalvaTesseramento() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
|
||||||
const dati = {
|
|
||||||
numero_tessera: input.dati.numeroTessera?.trim() || null,
|
|
||||||
data_tessera: input.dati.dataTessera || null,
|
|
||||||
};
|
|
||||||
const { error } = await supabaseNuoveTabelle
|
|
||||||
.from("giocatori_squadra")
|
|
||||||
.update(dati)
|
|
||||||
.eq("id", input.giocatoreId);
|
|
||||||
if (error) throw error;
|
|
||||||
return {
|
|
||||||
giocatoreId: input.giocatoreId,
|
|
||||||
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
onSuccess: (input) => {
|
|
||||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
|
||||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
|
* 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`
|
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export type TipoNotifica =
|
|||||||
| "sollecita_presenze_24h"
|
| "sollecita_presenze_24h"
|
||||||
| "sollecita_presenze_12h"
|
| "sollecita_presenze_12h"
|
||||||
| "sollecita_presenze_6h"
|
| "sollecita_presenze_6h"
|
||||||
| "sondaggio_cacche";
|
| "sondaggio_cacche"
|
||||||
|
| "compleanno"
|
||||||
|
| "compleanno_auguri";
|
||||||
|
|
||||||
export type NotificaUtente = {
|
export type NotificaUtente = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+239
-17
@@ -14,6 +14,25 @@ export type ObiettivoSquadra = {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
|
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
|
||||||
impatto: string;
|
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 = {
|
export type ContestoObiettivi = {
|
||||||
@@ -31,6 +50,24 @@ function meseCorrente(oggi: Date): string {
|
|||||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
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. */
|
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
||||||
function aMese(oggi: Date): string {
|
function aMese(oggi: Date): string {
|
||||||
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
||||||
@@ -49,16 +86,30 @@ function fineMese(oggi: Date): string {
|
|||||||
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
|
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
|
/**
|
||||||
const delMese = ctx.eventi.filter(
|
* Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da
|
||||||
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
* `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.
|
||||||
if (delMese.length === 0 || rosaSize === 0) return 0;
|
*/
|
||||||
const posti = delMese.length * rosaSize;
|
function percentualePresenze(
|
||||||
const presenti = delMese.reduce((s, e) => {
|
ctx: ContestoObiettivi,
|
||||||
|
rosa: Giocatore[],
|
||||||
|
filtro: (e: Evento) => boolean = () => true,
|
||||||
|
) {
|
||||||
|
let posti = 0;
|
||||||
|
let presenti = 0;
|
||||||
|
for (const e of ctx.eventi) {
|
||||||
|
if (e.tipo !== "partita" && e.tipo !== "allenamento") continue;
|
||||||
|
if (!filtro(e)) continue;
|
||||||
|
const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id));
|
||||||
|
posti += attesi.length;
|
||||||
const risposte = ctx.presenze[e.id] ?? {};
|
const risposte = ctx.presenze[e.id] ?? {};
|
||||||
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
presenti += attesi.filter((g) => {
|
||||||
}, 0);
|
const stato = risposte[g.id];
|
||||||
|
return stato === "presente" || stato === "ritardo";
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
if (posti === 0) return 0;
|
||||||
return Math.round((presenti / posti) * 100);
|
return Math.round((presenti / posti) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +130,6 @@ export function obiettiviSquadra(
|
|||||||
ctx: ContestoObiettivi = contestoVuoto,
|
ctx: ContestoObiettivi = contestoVuoto,
|
||||||
oggi: Date = new Date(),
|
oggi: Date = new Date(),
|
||||||
): ObiettivoSquadra[] {
|
): 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 continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||||
const vittorie = ctx.vittorie ?? 0;
|
const vittorie = ctx.vittorie ?? 0;
|
||||||
const mese = meseCorrente(oggi);
|
const mese = meseCorrente(oggi);
|
||||||
@@ -88,12 +138,35 @@ export function obiettiviSquadra(
|
|||||||
id: "o1",
|
id: "o1",
|
||||||
titolo: `90% di presenze ${aMese(oggi)}`,
|
titolo: `90% di presenze ${aMese(oggi)}`,
|
||||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||||
valore: percentualePresenzeMese(ctx, rosa.length, mese),
|
valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
|
||||||
target: 90,
|
target: 90,
|
||||||
unita: "%",
|
unita: "%",
|
||||||
scadenza: fineMese(oggi),
|
scadenza: fineMese(oggi),
|
||||||
emoji: "📣",
|
emoji: "📣",
|
||||||
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
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",
|
id: "o2",
|
||||||
@@ -105,16 +178,55 @@ export function obiettiviSquadra(
|
|||||||
scadenza: fineMese(oggi),
|
scadenza: fineMese(oggi),
|
||||||
emoji: "⚡",
|
emoji: "⚡",
|
||||||
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
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",
|
id: "o7",
|
||||||
titolo: "250 presenze complessive",
|
titolo: "Presenze collettive",
|
||||||
descrizione: "Somma delle presenze di tutta la rosa in stagione",
|
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
|
||||||
valore: somma((g) => g.presenze),
|
valore: percentualePresenze(ctx, rosa),
|
||||||
target: 250,
|
target: 90,
|
||||||
unita: "presenze",
|
unita: "%",
|
||||||
emoji: "🤝",
|
emoji: "🤝",
|
||||||
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
|
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",
|
id: "o12",
|
||||||
@@ -125,6 +237,19 @@ export function obiettiviSquadra(
|
|||||||
unita: "di media",
|
unita: "di media",
|
||||||
emoji: "📝",
|
emoji: "📝",
|
||||||
impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.",
|
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",
|
id: "o13",
|
||||||
@@ -135,6 +260,16 @@ export function obiettiviSquadra(
|
|||||||
unita: "voti",
|
unita: "voti",
|
||||||
emoji: "🗳️",
|
emoji: "🗳️",
|
||||||
impatto: "Vota i compagni a fine partita: bastano due minuti.",
|
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",
|
id: "o11",
|
||||||
@@ -145,6 +280,21 @@ export function obiettiviSquadra(
|
|||||||
unita: "giocatori",
|
unita: "giocatori",
|
||||||
emoji: "🔗",
|
emoji: "🔗",
|
||||||
impatto: "Tieni viva la tua serie e sblocchi anche questo.",
|
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",
|
id: "o3",
|
||||||
@@ -155,6 +305,22 @@ export function obiettiviSquadra(
|
|||||||
unita: "vittorie",
|
unita: "vittorie",
|
||||||
emoji: "🎉",
|
emoji: "🎉",
|
||||||
impatto: "Una prestazione di gruppo e il primo passo è fatto.",
|
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",
|
id: "o4",
|
||||||
@@ -165,6 +331,22 @@ export function obiettiviSquadra(
|
|||||||
unita: "vittorie",
|
unita: "vittorie",
|
||||||
emoji: "🔥",
|
emoji: "🔥",
|
||||||
impatto: "Ogni vittoria ci avvicina ai playoff.",
|
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",
|
id: "o5",
|
||||||
@@ -175,16 +357,56 @@ export function obiettiviSquadra(
|
|||||||
unita: "vittorie",
|
unita: "vittorie",
|
||||||
emoji: "🏆",
|
emoji: "🏆",
|
||||||
impatto: "L'obiettivo grande: serve tutta la stagione insieme.",
|
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",
|
id: "o6",
|
||||||
titolo: "1 evento di squadra al mese",
|
titolo: "1 evento di squadra al mese",
|
||||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
// 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,
|
||||||
target: 1,
|
target: 1,
|
||||||
unita: "eventi",
|
unita: "eventi",
|
||||||
emoji: "🍕",
|
emoji: "🍕",
|
||||||
impatto: "Il gruppo si costruisce anche fuori dal campo.",
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-10
@@ -247,14 +247,17 @@ export type AvvisoCertificato = {
|
|||||||
giorni: number;
|
giorni: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Giocatore in rosa senza certificato caricato (DD-046). */
|
||||||
|
export type CertificatoMancante = Pick<AvvisoCertificato, "giocatoreId" | "nome" | "cognome">;
|
||||||
|
|
||||||
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||||
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||||
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa. Chi non ha
|
||||||
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
* ancora un certificato caricato non ha una scadenza: finisce in `mancanti` (DD-046).
|
||||||
* `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
|
* `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
|
||||||
*/
|
*/
|
||||||
export function avvisiCertificati(
|
export function avvisiCertificati(
|
||||||
@@ -262,13 +265,21 @@ export function avvisiCertificati(
|
|||||||
profili: Record<string, Profilo>,
|
profili: Record<string, Profilo>,
|
||||||
oggi: string,
|
oggi: string,
|
||||||
soglia: number = GIORNI_AVVISO_CERTIFICATO,
|
soglia: number = GIORNI_AVVISO_CERTIFICATO,
|
||||||
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
): {
|
||||||
|
scaduti: AvvisoCertificato[];
|
||||||
|
inScadenza: AvvisoCertificato[];
|
||||||
|
mancanti: CertificatoMancante[];
|
||||||
|
} {
|
||||||
const scaduti: AvvisoCertificato[] = [];
|
const scaduti: AvvisoCertificato[] = [];
|
||||||
const inScadenza: AvvisoCertificato[] = [];
|
const inScadenza: AvvisoCertificato[] = [];
|
||||||
|
const mancanti: CertificatoMancante[] = [];
|
||||||
for (const g of rosa) {
|
for (const g of rosa) {
|
||||||
if (!inRosa(g)) continue;
|
if (!inRosa(g)) continue;
|
||||||
const p = profili[g.id];
|
const p = profili[g.id];
|
||||||
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
if (!p?.certificatoScadenza || !p.certificatoPath) {
|
||||||
|
mancanti.push({ giocatoreId: g.id, nome: g.nome, cognome: g.cognome });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||||
if (giorni > soglia) continue;
|
if (giorni > soglia) continue;
|
||||||
const avviso: AvvisoCertificato = {
|
const avviso: AvvisoCertificato = {
|
||||||
@@ -282,14 +293,15 @@ export function avvisiCertificati(
|
|||||||
}
|
}
|
||||||
scaduti.sort(confrontaAvvisi);
|
scaduti.sort(confrontaAvvisi);
|
||||||
inScadenza.sort(confrontaAvvisi);
|
inScadenza.sort(confrontaAvvisi);
|
||||||
return { scaduti, inScadenza };
|
mancanti.sort((a, b) => a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome));
|
||||||
|
return { scaduti, inScadenza, mancanti };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio
|
* Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio
|
||||||
* certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa; `staff` sono gli
|
* certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa, e `personaleMancante`
|
||||||
* altri della rosa (soglia 7 giorni), solo per l'admin, senza il suo nome perché già nel
|
* dice che non l'ha ancora caricato (DD-046); `staff` sono gli altri della rosa (soglia 7
|
||||||
* personale.
|
* giorni), solo per l'admin, senza il suo nome perché già nel personale.
|
||||||
*/
|
*/
|
||||||
export function avvisiCertificatiUtente(
|
export function avvisiCertificatiUtente(
|
||||||
rosa: GiocatoreSquadra[],
|
rosa: GiocatoreSquadra[],
|
||||||
@@ -298,7 +310,8 @@ export function avvisiCertificatiUtente(
|
|||||||
utente: { admin: boolean; base: GiocatoreSquadra | null },
|
utente: { admin: boolean; base: GiocatoreSquadra | null },
|
||||||
): {
|
): {
|
||||||
personale: AvvisoCertificato | undefined;
|
personale: AvvisoCertificato | undefined;
|
||||||
staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null;
|
personaleMancante: boolean;
|
||||||
|
staff: ReturnType<typeof avvisiCertificati> | null;
|
||||||
} {
|
} {
|
||||||
const mio = utente.base
|
const mio = utente.base
|
||||||
? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
|
? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
|
||||||
@@ -311,5 +324,5 @@ export function avvisiCertificatiUtente(
|
|||||||
oggi,
|
oggi,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
return { personale, staff };
|
return { personale, personaleMancante: !!mio && mio.mancanti.length > 0, staff };
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,13 @@ function NotFoundComponent() {
|
|||||||
<div className="max-w-md text-center">
|
<div className="max-w-md text-center">
|
||||||
<h1 className="font-display-lg text-7xl text-foreground">404</h1>
|
<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>
|
<h2 className="mt-4 text-xl font-semibold text-foreground">Pagina non trovata</h2>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||||
La pagina che cerchi non esiste o è stata spostata.
|
La pagina che cerchi non esiste o è stata spostata.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Torna alla home
|
Torna alla home
|
||||||
</Link>
|
</Link>
|
||||||
@@ -43,7 +43,7 @@ function NotFoundComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
|||||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
Questa pagina non si è caricata
|
Questa pagina non si è caricata
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||||
Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.
|
Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||||
@@ -63,13 +63,13 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
|||||||
router.invalidate();
|
router.invalidate();
|
||||||
reset();
|
reset();
|
||||||
}}
|
}}
|
||||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Riprova
|
Riprova
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-sm font-semibold text-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-[15px] font-semibold text-foreground"
|
||||||
>
|
>
|
||||||
Torna alla home
|
Torna alla home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+24
-95
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
|
||||||
Bell,
|
Bell,
|
||||||
BellOff,
|
BellOff,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -33,11 +32,9 @@ import {
|
|||||||
useGiocatoriSquadra,
|
useGiocatoriSquadra,
|
||||||
useImpostaAttivo,
|
useImpostaAttivo,
|
||||||
useSalvaDatiSquadra,
|
useSalvaDatiSquadra,
|
||||||
useSalvaTesseramento,
|
|
||||||
useScollegaAccount,
|
useScollegaAccount,
|
||||||
validaDatiSquadra,
|
validaDatiSquadra,
|
||||||
type DatiSquadra,
|
type DatiSquadra,
|
||||||
type DatiTesseramento,
|
|
||||||
type GiocatoreSquadra,
|
type GiocatoreSquadra,
|
||||||
} from "@/lib/giocatori-squadra";
|
} from "@/lib/giocatori-squadra";
|
||||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||||
@@ -110,18 +107,11 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
const { righe } = useGiocatoriSquadra();
|
const { righe } = useGiocatoriSquadra();
|
||||||
const salvaSquadra = useSalvaDatiSquadra();
|
const salvaSquadra = useSalvaDatiSquadra();
|
||||||
const salvaProfilo = useSalvaProfilo();
|
const salvaProfilo = useSalvaProfilo();
|
||||||
const salvaTesseramento = useSalvaTesseramento();
|
|
||||||
const scollega = useScollegaAccount();
|
const scollega = useScollegaAccount();
|
||||||
const impostaAttivo = useImpostaAttivo();
|
const impostaAttivo = useImpostaAttivo();
|
||||||
|
|
||||||
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
||||||
const [bozza, setBozza] = useState<Profilo | 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 ?? {
|
const squadraCorrente: DatiSquadra = datiSquadra ?? {
|
||||||
nome: g.nome,
|
nome: g.nome,
|
||||||
@@ -131,7 +121,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
email: g.email,
|
email: g.email,
|
||||||
tipo: g.tipo,
|
tipo: g.tipo,
|
||||||
};
|
};
|
||||||
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
|
// L'allenatore non ha numero né ruolo in campo (DD-034).
|
||||||
const allenatore = isAllenatore(g);
|
const allenatore = isAllenatore(g);
|
||||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||||
|
|
||||||
@@ -167,16 +157,6 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confermaTesseramento() {
|
|
||||||
try {
|
|
||||||
await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente });
|
|
||||||
setTesseramento(null);
|
|
||||||
toast.success("Tesseramento aggiornato");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(messaggioErrore(e, "Salvataggio non riuscito"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confermaProfilo() {
|
async function confermaProfilo() {
|
||||||
try {
|
try {
|
||||||
await salvaProfilo.mutateAsync(profiloCorrente);
|
await salvaProfilo.mutateAsync(profiloCorrente);
|
||||||
@@ -204,7 +184,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-3 border-t border-border pt-3">
|
<div className="mt-3 space-y-3 border-t border-border pt-3">
|
||||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
|
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati squadra</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Campo label="Nome">
|
<Campo label="Nome">
|
||||||
<input
|
<input
|
||||||
@@ -264,54 +244,14 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={confermaSquadra}
|
onClick={confermaSquadra}
|
||||||
disabled={!datiSquadra || salvaSquadra.isPending}
|
disabled={!datiSquadra || salvaSquadra.isPending}
|
||||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Salva dati squadra
|
Salva dati squadra
|
||||||
</button>
|
</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 ? (
|
{allenatore ? (
|
||||||
<>
|
<>
|
||||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
|
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati personali</h3>
|
||||||
<CampiAllenatore
|
<CampiAllenatore
|
||||||
corrente={profiloCorrente}
|
corrente={profiloCorrente}
|
||||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||||
@@ -328,12 +268,12 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={confermaProfilo}
|
onClick={confermaProfilo}
|
||||||
disabled={!bozza || salvaProfilo.isPending}
|
disabled={!bozza || salvaProfilo.isPending}
|
||||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Salva dati personali
|
Salva dati personali
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-[13px]">
|
||||||
<span className="min-w-0 text-muted-foreground">
|
<span className="min-w-0 text-muted-foreground">
|
||||||
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
||||||
</span>
|
</span>
|
||||||
@@ -404,7 +344,7 @@ function Documento({
|
|||||||
onClick={scarica}
|
onClick={scarica}
|
||||||
disabled={!path || inCorso}
|
disabled={!path || inCorso}
|
||||||
className={cn(
|
className={cn(
|
||||||
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
|
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase disabled:opacity-60",
|
||||||
statoClasse[stato],
|
statoClasse[stato],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -442,12 +382,12 @@ function SchedaGiocatore({
|
|||||||
return (
|
return (
|
||||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
<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">
|
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-[15px] tabular-nums">
|
||||||
{allenatore ? "All" : numero}
|
{allenatore ? "All" : numero}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-semibold leading-tight">{nome}</p>
|
<p className="truncate font-semibold leading-tight">{nome}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{ruoloVisibile(g)} · profilo {perc}%
|
{ruoloVisibile(g)} · profilo {perc}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,15 +426,6 @@ function SchedaGiocatore({
|
|||||||
stato={certificato}
|
stato={certificato}
|
||||||
path={profilo?.certificatoPath ?? null}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -549,7 +480,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAperto(true)}
|
onClick={() => setAperto(true)}
|
||||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
||||||
</button>
|
</button>
|
||||||
@@ -558,7 +489,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
||||||
<h3 className="font-display text-sm uppercase tracking-wide">
|
<h3 className="font-display text-[15px] uppercase tracking-wide">
|
||||||
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
||||||
</h3>
|
</h3>
|
||||||
<Campo label="Tipo">
|
<Campo label="Tipo">
|
||||||
@@ -632,7 +563,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
setAperto(false);
|
setAperto(false);
|
||||||
setDati(datiVuoti);
|
setDati(datiVuoti);
|
||||||
}}
|
}}
|
||||||
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
|
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-[13px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -640,7 +571,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void conferma()}
|
onClick={() => void conferma()}
|
||||||
disabled={aggiungi.isPending}
|
disabled={aggiungi.isPending}
|
||||||
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Aggiungi
|
Aggiungi
|
||||||
</button>
|
</button>
|
||||||
@@ -667,7 +598,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
|||||||
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
|
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -675,7 +606,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void riattiva()}
|
onClick={() => void riattiva()}
|
||||||
disabled={impostaAttivo.isPending}
|
disabled={impostaAttivo.isPending}
|
||||||
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
|
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-[13px] font-bold text-success-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
||||||
</button>
|
</button>
|
||||||
@@ -722,7 +653,7 @@ function Dashboard() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
|
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
|
||||||
<div className="px-5 pt-4">
|
<div className="px-5 pt-4">
|
||||||
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
<Lock className="h-4 w-4 shrink-0" />
|
<Lock className="h-4 w-4 shrink-0" />
|
||||||
Riservata agli amministratori della squadra.
|
Riservata agli amministratori della squadra.
|
||||||
</p>
|
</p>
|
||||||
@@ -743,7 +674,6 @@ function Dashboard() {
|
|||||||
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
|
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
|
||||||
"valido",
|
"valido",
|
||||||
).length;
|
).length;
|
||||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
|
||||||
|
|
||||||
const contenutoSquadra = (
|
const contenutoSquadra = (
|
||||||
<>
|
<>
|
||||||
@@ -755,12 +685,11 @@ function Dashboard() {
|
|||||||
label="Certificati validi"
|
label="Certificati validi"
|
||||||
hint="non scaduti"
|
hint="non scaduti"
|
||||||
/>
|
/>
|
||||||
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||||
</button>
|
</button>
|
||||||
@@ -769,7 +698,7 @@ function Dashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contenutoProfili = isPending ? (
|
const contenutoProfili = isPending ? (
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Caricamento…
|
Caricamento…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -786,7 +715,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{allenatori.length > 0 ? (
|
{allenatori.length > 0 ? (
|
||||||
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
|
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
|
||||||
) : null}
|
) : null}
|
||||||
{allenatori.map((g, i) => (
|
{allenatori.map((g, i) => (
|
||||||
<SchedaGiocatore
|
<SchedaGiocatore
|
||||||
@@ -819,7 +748,7 @@ function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
||||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||||
</button>
|
</button>
|
||||||
@@ -835,7 +764,7 @@ function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
|
||||||
!attiva && "text-muted-foreground",
|
!attiva && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -855,7 +784,7 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Caricamento…
|
Caricamento…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -909,7 +838,7 @@ function Dashboard() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={inviaMessaggio}
|
onClick={inviaMessaggio}
|
||||||
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{inviaNotifica.isPending ? (
|
{inviaNotifica.isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -921,7 +850,7 @@ function Dashboard() {
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ function AllenamentoDetail() {
|
|||||||
if (!evento) {
|
if (!evento) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 pt-8">
|
<div className="px-5 pt-8">
|
||||||
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
|
<p className="mt-8 text-center text-[15px] text-muted-foreground">
|
||||||
|
Allenamento non trovato
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -59,14 +61,14 @@ function AllenamentoDetail() {
|
|||||||
<Dumbbell className="h-6 w-6" />
|
<Dumbbell className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Allenamento di squadra
|
Allenamento di squadra
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" /> {evento.ora}
|
<Clock className="h-4 w-4" /> {evento.ora}
|
||||||
</span>
|
</span>
|
||||||
@@ -76,12 +78,12 @@ function AllenamentoDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||||
{evento.note}
|
{evento.note}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,34 +107,34 @@ function Benvenuto() {
|
|||||||
|
|
||||||
{!pronta ? null : !utenteId ? (
|
{!pronta ? null : !utenteId ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||||
Accedi con il tuo account Google per collegare il profilo giocatore.
|
Accedi con il tuo account Google per collegare il profilo giocatore.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={accedi}
|
onClick={accedi}
|
||||||
disabled={inCorso}
|
disabled={inCorso}
|
||||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<LogIn className="h-4 w-4" /> Accedi con Google
|
<LogIn className="h-4 w-4" /> Accedi con Google
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : erroreCollegamento ? (
|
) : erroreCollegamento ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||||
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
|
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
|
||||||
account.
|
account.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void esciERiprova()}
|
onClick={() => void esciERiprova()}
|
||||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
|
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-[15px] font-bold uppercase shadow-card"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" /> Esci
|
<LogOut className="h-4 w-4" /> Esci
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : inAttesaCollegamento ? (
|
) : inAttesaCollegamento ? (
|
||||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||||
Ti stiamo collegando al tuo profilo...
|
Ti stiamo collegando al tuo profilo...
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function Calendario() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
|
||||||
{giorniIT.map((g, i) => (
|
{giorniIT.map((g, i) => (
|
||||||
<span key={i}>{g}</span>
|
<span key={i}>{g}</span>
|
||||||
))}
|
))}
|
||||||
@@ -192,7 +192,7 @@ function Calendario() {
|
|||||||
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
|
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
|
||||||
style={sfondo ? { backgroundImage: sfondo } : undefined}
|
style={sfondo ? { backgroundImage: sfondo } : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
|
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold",
|
||||||
tipo === "partita" && "bg-accent text-accent-foreground",
|
tipo === "partita" && "bg-accent text-accent-foreground",
|
||||||
tipo === "allenamento" && "bg-training text-training-foreground",
|
tipo === "allenamento" && "bg-training text-training-foreground",
|
||||||
tipo === "evento" && "bg-warning text-warning-foreground",
|
tipo === "evento" && "bg-warning text-warning-foreground",
|
||||||
@@ -224,10 +224,10 @@ function Calendario() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
Scorri a destra o sinistra per cambiare mese.
|
Scorri a destra o sinistra per cambiare mese.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
|
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
|
||||||
</span>
|
</span>
|
||||||
@@ -248,7 +248,7 @@ function Calendario() {
|
|||||||
<div className="px-5 pt-4">
|
<div className="px-5 pt-4">
|
||||||
<Link
|
<Link
|
||||||
to="/eventi"
|
to="/eventi"
|
||||||
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[14px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
|
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
|
||||||
</Link>
|
</Link>
|
||||||
@@ -263,7 +263,7 @@ function Calendario() {
|
|||||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Nessun evento in programma
|
Nessun evento in programma
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -275,7 +275,7 @@ function Calendario() {
|
|||||||
{compleanniMese.length > 0 ? (
|
{compleanniMese.length > 0 ? (
|
||||||
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
|
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
|
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -300,7 +300,7 @@ function Calendario() {
|
|||||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Nessun evento in questa data
|
Nessun evento in questa data
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+15
-15
@@ -82,7 +82,7 @@ export const Route = createFileRoute("/classifica")({
|
|||||||
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
|
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-[13px] font-bold uppercase text-muted-foreground">
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>Squadra</span>
|
<span>Squadra</span>
|
||||||
<span className="text-center">G</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>
|
<span className="text-center">Pt</span>
|
||||||
</div>
|
</div>
|
||||||
{righe.length === 0 ? (
|
{righe.length === 0 ? (
|
||||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{vuoto}</p>
|
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">{vuoto}</p>
|
||||||
) : (
|
) : (
|
||||||
righe.map((r) => {
|
righe.map((r) => {
|
||||||
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
||||||
@@ -98,14 +98,14 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
|
|||||||
<div
|
<div
|
||||||
key={r.pos}
|
key={r.pos}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-[15px] last:border-0",
|
||||||
noi && "bg-accent/10",
|
noi && "bg-accent/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn("font-display text-base", noi && "text-accent")}>{r.pos}</span>
|
<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={cn("truncate", noi ? "font-bold" : "font-medium")}>{r.squadra}</span>
|
||||||
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
|
<span className="text-center text-[13px] text-muted-foreground">{r.giocate}</span>
|
||||||
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
<span className="text-center text-[13px] tabular-nums text-muted-foreground">
|
||||||
{r.setFatti}:{r.setSubiti}
|
{r.setFatti}:{r.setSubiti}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
||||||
@@ -171,7 +171,7 @@ function Classifica() {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
|
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-[13px]",
|
||||||
csi?.formatoSospetto
|
csi?.formatoSospetto
|
||||||
? "bg-warning/15 text-warning"
|
? "bg-warning/15 text-warning"
|
||||||
: "bg-secondary text-muted-foreground",
|
: "bg-secondary text-muted-foreground",
|
||||||
@@ -190,9 +190,9 @@ function Classifica() {
|
|||||||
</div>
|
</div>
|
||||||
{classificaCoppa.length > 0 ? (
|
{classificaCoppa.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
|
<h3 className="mb-2 px-1 text-[15px] font-bold text-foreground">Coppa</h3>
|
||||||
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
||||||
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
|
<h3 className="mb-2 mt-4 px-1 text-[15px] font-bold text-foreground">Girone</h3>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
|
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
|
||||||
@@ -204,14 +204,14 @@ function Classifica() {
|
|||||||
const contenutoStorico = useMemo(
|
const contenutoStorico = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tuttiMatch.length === 0 ? (
|
tuttiMatch.length === 0 ? (
|
||||||
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-[13px] text-muted-foreground shadow-card">
|
||||||
Nessuna partita disponibile.
|
Nessuna partita disponibile.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
|
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
|
||||||
<section key={stagione} className="space-y-3">
|
<section key={stagione} className="space-y-3">
|
||||||
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
|
<h3 className="px-1 text-[15px] font-bold text-foreground">Stagione {stagione}</h3>
|
||||||
{match.map((m) => {
|
{match.map((m) => {
|
||||||
const vinta = m.setNostri > m.setLoro;
|
const vinta = m.setNostri > m.setLoro;
|
||||||
const eventoId = eventoIdPerData.get(m.data);
|
const eventoId = eventoIdPerData.get(m.data);
|
||||||
@@ -237,7 +237,7 @@ function Classifica() {
|
|||||||
logoAvversario={m.logoAvversario}
|
logoAvversario={m.logoAvversario}
|
||||||
avversario={m.avversario}
|
avversario={m.avversario}
|
||||||
/>
|
/>
|
||||||
<p className="truncate text-sm font-bold">{casa.nome}</p>
|
<p className="truncate text-[15px] font-bold">{casa.nome}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<LogoPartita
|
<LogoPartita
|
||||||
@@ -245,9 +245,9 @@ function Classifica() {
|
|||||||
logoAvversario={m.logoAvversario}
|
logoAvversario={m.logoAvversario}
|
||||||
avversario={m.avversario}
|
avversario={m.avversario}
|
||||||
/>
|
/>
|
||||||
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
|
<p className="truncate text-[15px] font-bold">{trasferta.nome}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||||
{m.scout ? " · scoutata" : ""}
|
{m.scout ? " · scoutata" : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -278,7 +278,7 @@ function Classifica() {
|
|||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
"rounded-lg px-2 py-1 text-[13px] font-semibold tabular-nums",
|
||||||
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
|
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -287,7 +287,7 @@ function Classifica() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{eventoId && !m.mvp ? (
|
{eventoId && !m.mvp ? (
|
||||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
<span className="ml-auto inline-flex items-center gap-0.5 text-[13px] font-bold uppercase text-accent">
|
||||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+26
-24
@@ -91,7 +91,7 @@ function GestioneEventi() {
|
|||||||
<>
|
<>
|
||||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||||
<div className="px-5">
|
<div className="px-5">
|
||||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Solo i referenti della squadra possono creare o modificare gli eventi.
|
Solo i referenti della squadra possono creare o modificare gli eventi.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +207,7 @@ function GestioneEventi() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
|
||||||
{giorniIT.map((g, i) => (
|
{giorniIT.map((g, i) => (
|
||||||
<span key={i}>{g}</span>
|
<span key={i}>{g}</span>
|
||||||
))}
|
))}
|
||||||
@@ -248,7 +248,7 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => apriGiorno(giorno)}
|
onClick={() => apriGiorno(giorno)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold transition-transform active:scale-90",
|
||||||
haEventi
|
haEventi
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-foreground",
|
: "bg-secondary text-foreground",
|
||||||
@@ -265,11 +265,11 @@ function GestioneEventi() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
|
<p aria-busy="true" className="mt-2 text-[13px] text-muted-foreground">
|
||||||
Carico gli eventi…
|
Carico gli eventi…
|
||||||
</p>
|
</p>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="mt-3 space-y-2 text-center text-sm">
|
<div className="mt-3 space-y-2 text-center text-[15px]">
|
||||||
<p className="text-destructive">
|
<p className="text-destructive">
|
||||||
Non sono riuscito a caricare gli eventi
|
Non sono riuscito a caricare gli eventi
|
||||||
{error instanceof Error ? `: ${error.message}` : ""}.
|
{error instanceof Error ? `: ${error.message}` : ""}.
|
||||||
@@ -277,13 +277,13 @@ function GestioneEventi() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
|
className="rounded-2xl bg-secondary px-4 py-2 text-[13px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
Riprova
|
Riprova
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
|
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
|
||||||
modificare o eliminare.
|
modificare o eliminare.
|
||||||
</p>
|
</p>
|
||||||
@@ -303,7 +303,7 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => aggiorna(daCategoria(t.id))}
|
onClick={() => aggiorna(daCategoria(t.id))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full py-2 text-xs font-bold uppercase transition-colors",
|
"rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
|
||||||
categoriaEvento(bozza) === t.id
|
categoriaEvento(bozza) === t.id
|
||||||
? "bg-card shadow-card text-foreground"
|
? "bg-card shadow-card text-foreground"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
@@ -337,15 +337,17 @@ function GestioneEventi() {
|
|||||||
</Campo>
|
</Campo>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Giorno
|
Giorno
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1 flex h-10 items-center justify-between gap-1">
|
<div className="mt-1 flex h-10 items-center justify-between gap-1">
|
||||||
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
|
<span className="truncate text-[15px] font-semibold">
|
||||||
|
{formatData(bozza.data)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCambiaGiorno(true)}
|
onClick={() => setCambiaGiorno(true)}
|
||||||
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
|
className="shrink-0 rounded-full px-2 py-1 text-[13px] font-bold uppercase text-accent active:scale-95"
|
||||||
>
|
>
|
||||||
Cambia
|
Cambia
|
||||||
</button>
|
</button>
|
||||||
@@ -394,7 +396,7 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => aggiorna({ casa: o.casa })}
|
onClick={() => aggiorna({ casa: o.casa })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors",
|
"flex-1 rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
|
||||||
bozza.casa === o.casa
|
bozza.casa === o.casa
|
||||||
? "bg-card shadow-card text-foreground"
|
? "bg-card shadow-card text-foreground"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
@@ -433,7 +435,7 @@ function GestioneEventi() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||||
scelto
|
scelto
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-muted-foreground",
|
: "bg-secondary text-muted-foreground",
|
||||||
@@ -450,7 +452,7 @@ function GestioneEventi() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setBozza(null)}
|
onClick={() => setBozza(null)}
|
||||||
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
className="flex-1 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -458,7 +460,7 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={chiediConferma}
|
onClick={chiediConferma}
|
||||||
disabled={salva.isPending}
|
disabled={salva.isPending}
|
||||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
|
||||||
</button>
|
</button>
|
||||||
@@ -489,8 +491,8 @@ function GestioneEventi() {
|
|||||||
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
<p className="truncate text-[15px] font-bold leading-tight">{e.titolo}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{e.ora} · {e.luogo || "luogo da definire"}
|
{e.ora} · {e.luogo || "luogo da definire"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,7 +515,7 @@ function GestioneEventi() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
Nessun evento in programma
|
Nessun evento in programma
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -521,7 +523,7 @@ function GestioneEventi() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={nuovoNelGiorno}
|
onClick={nuovoNelGiorno}
|
||||||
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||||
</button>
|
</button>
|
||||||
@@ -544,14 +546,14 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={conferma}
|
onClick={conferma}
|
||||||
disabled={salva.isPending}
|
disabled={salva.isPending}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
||||||
</button>
|
</button>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -573,14 +575,14 @@ function GestioneEventi() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => daEliminare && rimuovi(daEliminare.id)}
|
onClick={() => daEliminare && rimuovi(daEliminare.id)}
|
||||||
disabled={elimina.isPending}
|
disabled={elimina.isPending}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-[15px] font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
|
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
|
||||||
</button>
|
</button>
|
||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -606,7 +608,7 @@ function Interruttore({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-3 py-1.5 text-xs font-bold uppercase transition-colors",
|
"rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors",
|
||||||
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
|
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function EventoDetail() {
|
|||||||
if (!evento) {
|
if (!evento) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 pt-8">
|
<div className="px-5 pt-8">
|
||||||
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
|
<p className="mt-8 text-center text-[15px] text-muted-foreground">Evento non trovato</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,14 +58,14 @@ function EventoDetail() {
|
|||||||
<PartyPopper className="h-6 w-6" />
|
<PartyPopper className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Evento extra-campo
|
Evento extra-campo
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" /> {evento.ora}
|
<Clock className="h-4 w-4" /> {evento.ora}
|
||||||
</span>
|
</span>
|
||||||
@@ -75,12 +75,12 @@ function EventoDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||||
{evento.note}
|
{evento.note}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+42
-27
@@ -9,6 +9,7 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
|||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
import { Barra } from "@/components/motion/Barra";
|
import { Barra } from "@/components/motion/Barra";
|
||||||
import { Numero } from "@/components/motion/Numero";
|
import { Numero } from "@/components/motion/Numero";
|
||||||
|
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||||
import { useEventi, type Evento } from "@/lib/eventi";
|
import { useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useRispostePresenze } from "@/lib/presenze";
|
import { useRispostePresenze } from "@/lib/presenze";
|
||||||
@@ -85,7 +86,7 @@ function Index() {
|
|||||||
className="h-14 w-14 rounded-full shadow-pop ring-2 ring-primary-foreground/25"
|
className="h-14 w-14 rounded-full shadow-pop ring-2 ring-primary-foreground/25"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
<p className="text-[13px] font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||||
Ciao {nome.split(" ")[0]}
|
Ciao {nome.split(" ")[0]}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
||||||
@@ -99,13 +100,13 @@ function Index() {
|
|||||||
>
|
>
|
||||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||||
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
|
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
|
||||||
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
|
<p className="text-[13px] uppercase text-primary-foreground/80">In classifica</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||||
<p className="font-display text-2xl leading-none">
|
<p className="font-display text-2xl leading-none">
|
||||||
{noi ? `${noi.vinte}-${noi.perse}` : "—"}
|
{noi ? `${noi.vinte}-${noi.perse}` : "—"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
|
<p className="text-[13px] uppercase text-primary-foreground/80">Bilancio W-L</p>
|
||||||
</div>
|
</div>
|
||||||
{giocatore ? (
|
{giocatore ? (
|
||||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||||
@@ -113,7 +114,7 @@ function Index() {
|
|||||||
<Flame className="h-4 w-4 text-accent" />
|
<Flame className="h-4 w-4 text-accent" />
|
||||||
{giocatore.streak}
|
{giocatore.streak}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
<p className="text-[13px] uppercase text-primary-foreground/80">Streak</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +132,7 @@ function Index() {
|
|||||||
azione={
|
azione={
|
||||||
<Link
|
<Link
|
||||||
to="/calendario"
|
to="/calendario"
|
||||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Calendario <ChevronRight className="h-4 w-4" />
|
Calendario <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -140,7 +141,7 @@ function Index() {
|
|||||||
{prossimo ? (
|
{prossimo ? (
|
||||||
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
|
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||||
Nessun impegno in programma.
|
Nessun impegno in programma.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +156,7 @@ function Index() {
|
|||||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||||
Nient'altro da confermare: sei in pari.
|
Nient'altro da confermare: sei in pari.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +171,7 @@ function Index() {
|
|||||||
<Link
|
<Link
|
||||||
to="/classifica"
|
to="/classifica"
|
||||||
search={{ tab: "storico" }}
|
search={{ tab: "storico" }}
|
||||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Storico <ChevronRight className="h-4 w-4" />
|
Storico <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -182,8 +183,10 @@ function Index() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
|
<p className="truncate text-[15px] font-bold">
|
||||||
<p className="text-xs text-muted-foreground">
|
CRAP Volley vs {ultima.avversario}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{ultima.casa ? "In casa" : "Trasferta"}
|
{ultima.casa ? "In casa" : "Trasferta"}
|
||||||
{ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}
|
{ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}
|
||||||
</p>
|
</p>
|
||||||
@@ -196,7 +199,7 @@ function Index() {
|
|||||||
{ultima.parziali.map((p, i) => (
|
{ultima.parziali.map((p, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{p[0]}-{p[1]}
|
{p[0]}-{p[1]}
|
||||||
</span>
|
</span>
|
||||||
@@ -217,7 +220,7 @@ function Index() {
|
|||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||||
Nessun risultato disponibile.
|
Nessun risultato disponibile.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -230,27 +233,39 @@ function Index() {
|
|||||||
azione={
|
azione={
|
||||||
<Link
|
<Link
|
||||||
to="/squadra"
|
to="/squadra"
|
||||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Tutti <ChevronRight className="h-4 w-4" />
|
Tutti <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{obiettivo ? (
|
{obiettivo ? (
|
||||||
<Card>
|
<ObiettivoDrawer obiettivo={obiettivo}>
|
||||||
<div className="flex items-center gap-2 text-sm font-bold">
|
<button
|
||||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
type="button"
|
||||||
</div>
|
aria-label={`Dettaglio obiettivo: ${obiettivo.titolo}`}
|
||||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
className="block w-full text-left"
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
>
|
||||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
<Card>
|
||||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
<div className="flex items-center gap-2 text-[15px] font-bold">
|
||||||
</p>
|
<span className="text-base leading-none">{obiettivo.emoji}</span>{" "}
|
||||||
<p className="mt-1 text-xs font-semibold text-accent">
|
{obiettivo.titolo}
|
||||||
{microcopyObiettivo(obiettivo)}
|
</div>
|
||||||
</p>
|
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
</Card>
|
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||||
|
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] font-semibold text-accent">
|
||||||
|
{microcopyObiettivo(obiettivo)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||||
|
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
|
||||||
|
Come funziona <ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
</ObiettivoDrawer>
|
||||||
) : null}
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function PartitaCsiDetail() {
|
|||||||
if (!partita) {
|
if (!partita) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 pt-8">
|
<div className="px-5 pt-8">
|
||||||
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +69,7 @@ function PartitaCsiDetail() {
|
|||||||
className="h-10 w-10"
|
className="h-10 w-10"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{partita.casa ? "In casa" : "Fuori casa"}
|
{partita.casa ? "In casa" : "Fuori casa"}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</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) => (
|
{partita.parziali.map(([noi, loro], i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{noi}-{loro}
|
{noi}-{loro}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+15
-13
@@ -60,7 +60,7 @@ function PartitaDetail() {
|
|||||||
if (!evento) {
|
if (!evento) {
|
||||||
return (
|
return (
|
||||||
<div className="px-5 pt-8">
|
<div className="px-5 pt-8">
|
||||||
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ function PartitaDetail() {
|
|||||||
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
|
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{casa ? "In casa" : "Fuori casa"}
|
{casa ? "In casa" : "Fuori casa"}
|
||||||
{evento.campionato ? " · Campionato" : " · Amichevole"}
|
{evento.campionato ? " · Campionato" : " · Amichevole"}
|
||||||
</p>
|
</p>
|
||||||
@@ -126,7 +126,7 @@ function PartitaDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4" /> {evento.ora}
|
<Clock className="h-4 w-4" /> {evento.ora}
|
||||||
</span>
|
</span>
|
||||||
@@ -136,12 +136,12 @@ function PartitaDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||||
{evento.note}
|
{evento.note}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Conferme: {presentiVeri}/{convocati.length}
|
Conferme: {presentiVeri}/{convocati.length}
|
||||||
</div>
|
</div>
|
||||||
@@ -163,7 +163,7 @@ function PartitaDetail() {
|
|||||||
<Section titolo="Risultato">
|
<Section titolo="Risultato">
|
||||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-bold">CRAP Volley</span>
|
<span className="text-[15px] font-bold">CRAP Volley</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-3 py-1 text-lg font-display font-bold",
|
"rounded-full px-3 py-1 text-lg font-display font-bold",
|
||||||
@@ -174,7 +174,7 @@ function PartitaDetail() {
|
|||||||
>
|
>
|
||||||
{match.setNostri} - {match.setLoro}
|
{match.setNostri} - {match.setLoro}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-right text-sm font-bold">{avversario}</span>
|
<span className="text-right text-[15px] font-bold">{avversario}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
{match.parziali.map(([noi, loro], i) => (
|
{match.parziali.map(([noi, loro], i) => (
|
||||||
@@ -183,7 +183,9 @@ function PartitaDetail() {
|
|||||||
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
|
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
|
||||||
>
|
>
|
||||||
<span className="font-display text-lg">{noi}</span>
|
<span className="font-display text-lg">{noi}</span>
|
||||||
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
|
<span className="text-[13px] font-semibold text-muted-foreground">
|
||||||
|
Set {i + 1}
|
||||||
|
</span>
|
||||||
<span className="font-display text-lg">{loro}</span>
|
<span className="font-display text-lg">{loro}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -213,7 +215,7 @@ function PartitaDetail() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Section titolo="In programma">
|
<Section titolo="In programma">
|
||||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||||
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
||||||
risultato.
|
risultato.
|
||||||
</p>
|
</p>
|
||||||
@@ -248,13 +250,13 @@ function PartitaDetail() {
|
|||||||
].map((t) => (
|
].map((t) => (
|
||||||
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
||||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||||
{t.l}
|
{t.l}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Dettaglio giocatori (uso interno allenatori)
|
Dettaglio giocatori (uso interno allenatori)
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
@@ -264,7 +266,7 @@ function PartitaDetail() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={gid}
|
key={gid}
|
||||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
|
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||||
#{g.numero} {g.nome}
|
#{g.numero} {g.nome}
|
||||||
@@ -285,7 +287,7 @@ function PartitaDetail() {
|
|||||||
csvScoutMatch(scout, rosa),
|
csvScoutMatch(scout, rosa),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV
|
<Download className="h-4 w-4" /> Esporta CSV
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+10
-10
@@ -220,7 +220,7 @@ function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-[13px] font-bold uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Cambia immagine profilo
|
Cambia immagine profilo
|
||||||
</button>
|
</button>
|
||||||
@@ -312,11 +312,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={cambiaNotifiche}
|
onClick={cambiaNotifiche}
|
||||||
disabled={!supportate || inCorso}
|
disabled={!supportate || inCorso}
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate">Notifiche</span>
|
<span className="block truncate">Notifiche</span>
|
||||||
<span className="block text-xs text-muted-foreground">
|
<span className="block text-[13px] text-muted-foreground">
|
||||||
{supportate
|
{supportate
|
||||||
? notifiche
|
? notifiche
|
||||||
? "Attive su questo dispositivo"
|
? "Attive su questo dispositivo"
|
||||||
@@ -339,11 +339,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={cambiaEmail}
|
onClick={cambiaEmail}
|
||||||
disabled={!email.pronta || email.imposta.isPending}
|
disabled={!email.pronta || email.imposta.isPending}
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate">Email</span>
|
<span className="block truncate">Email</span>
|
||||||
<span className="block text-xs text-muted-foreground">
|
<span className="block text-[13px] text-muted-foreground">
|
||||||
{email.attive
|
{email.attive
|
||||||
? "Le notifiche arrivano anche via email"
|
? "Le notifiche arrivano anche via email"
|
||||||
: "Nessuna email, solo notifiche in app"}
|
: "Nessuna email, solo notifiche in app"}
|
||||||
@@ -363,7 +363,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
|||||||
{admin ? (
|
{admin ? (
|
||||||
<Link
|
<Link
|
||||||
to="/admin"
|
to="/admin"
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
<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"
|
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
<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"
|
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
<p className="text-center text-[13px] text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-[15px] font-bold text-destructive shadow-card"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Esci
|
Esci
|
||||||
|
|||||||
+23
-23
@@ -72,7 +72,7 @@ function Blocco({
|
|||||||
{icona}
|
{icona}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
|
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{testo}</p>
|
<p className="mt-2 text-[15px] text-muted-foreground">{testo}</p>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +138,7 @@ function Scout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => sessione.refetch()}
|
onClick={() => sessione.refetch()}
|
||||||
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
Aggiorna
|
Aggiorna
|
||||||
</button>
|
</button>
|
||||||
@@ -156,7 +156,7 @@ function Scout() {
|
|||||||
if (ok) setControllo(true);
|
if (ok) setControllo(true);
|
||||||
else toast.error("Un altro compagno ha appena preso lo scout");
|
else toast.error("Un altro compagno ha appena preso lo scout");
|
||||||
}}
|
}}
|
||||||
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
|
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
|
||||||
</button>
|
</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">
|
<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">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-xs font-bold uppercase tracking-wide">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-[13px] font-bold uppercase tracking-wide">
|
||||||
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
|
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -321,14 +321,14 @@ function ScoutBoard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={annulla}
|
onClick={annulla}
|
||||||
disabled={azioniSet.length === 0}
|
disabled={azioniSet.length === 0}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
|
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onFine}
|
onClick={onFine}
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
|
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold"
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" /> Rilascia
|
<LogOut className="h-3.5 w-3.5" /> Rilascia
|
||||||
</button>
|
</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="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
|
<p className="truncate text-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
|
||||||
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
|
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-display text-2xl leading-none text-primary-foreground/50">
|
<p className="font-display text-2xl leading-none text-primary-foreground/50">
|
||||||
{setNostri}-{setLoro}
|
{setNostri}-{setLoro}
|
||||||
</p>
|
</p>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-xs uppercase text-primary-foreground/60">
|
<p className="truncate text-[13px] uppercase text-primary-foreground/60">
|
||||||
{avversario || "Avversario"}
|
{avversario || "Avversario"}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
|
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
|
||||||
@@ -356,7 +356,7 @@ function ScoutBoard({
|
|||||||
{setChiusi.map((p, i) => (
|
{setChiusi.map((p, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs font-semibold tabular-nums"
|
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-[13px] font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{p[0]}-{p[1]}
|
{p[0]}-{p[1]}
|
||||||
</span>
|
</span>
|
||||||
@@ -366,7 +366,7 @@ function ScoutBoard({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="px-5 pt-4">
|
<section className="px-5 pt-4">
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
1. Tocca il giocatore
|
1. Tocca il giocatore
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<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="font-display text-2xl leading-none">{g.numero}</p>
|
||||||
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
|
<p className="mt-1 text-[13px] font-bold leading-tight">{g.nome}</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xs tabular-nums",
|
"text-[13px] tabular-nums",
|
||||||
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
|
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -400,7 +400,7 @@ function ScoutBoard({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="px-5 pt-4">
|
<section className="px-5 pt-4">
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
2. Tocca l'azione
|
2. Tocca l'azione
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -426,7 +426,7 @@ function ScoutBoard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => registra(tipo)}
|
onClick={() => registra(tipo)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
|
"rounded-2xl py-3 text-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
|
||||||
azioniMeta[tipo].className,
|
azioniMeta[tipo].className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -438,11 +438,11 @@ function ScoutBoard({
|
|||||||
|
|
||||||
<section className="px-5 pt-4">
|
<section className="px-5 pt-4">
|
||||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Ultime azioni
|
Ultime azioni
|
||||||
</p>
|
</p>
|
||||||
{ultime.length === 0 ? (
|
{ultime.length === 0 ? (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
|
<p className="mt-2 text-[13px] text-muted-foreground">Nessuna azione registrata.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-1.5">
|
<ul className="mt-2 space-y-1.5">
|
||||||
{ultime.map((a, i) => {
|
{ultime.map((a, i) => {
|
||||||
@@ -451,14 +451,14 @@ function ScoutBoard({
|
|||||||
<li
|
<li
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-2 px-1 text-xs",
|
"flex items-center justify-between gap-2 px-1 text-[13px]",
|
||||||
i === 0 && "anim-riga",
|
i === 0 && "anim-riga",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
|
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-bold uppercase",
|
"shrink-0 rounded-full px-2 py-0.5 text-[13px] font-bold uppercase",
|
||||||
azioniMeta[a.tipo].className,
|
azioniMeta[a.tipo].className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -474,14 +474,14 @@ function ScoutBoard({
|
|||||||
|
|
||||||
<section className="px-5 pt-4">
|
<section className="px-5 pt-4">
|
||||||
<Card>
|
<Card>
|
||||||
<label className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<label className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Avversario
|
Avversario
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={avversario}
|
value={avversario}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
onChange={(e) => setAvversario(e.target.value)}
|
onChange={(e) => setAvversario(e.target.value)}
|
||||||
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-[15px]"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex rounded-full bg-secondary p-1">
|
<div className="mt-3 flex rounded-full bg-secondary p-1">
|
||||||
{[true, false].map((v) => (
|
{[true, false].map((v) => (
|
||||||
@@ -490,7 +490,7 @@ function ScoutBoard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCasa(v)}
|
onClick={() => setCasa(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
|
"flex-1 rounded-full py-1.5 text-[13px] font-bold uppercase",
|
||||||
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
|
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -505,7 +505,7 @@ function ScoutBoard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={chiudiSet}
|
onClick={chiudiSet}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" /> Chiudi set
|
<CheckCircle2 className="h-4 w-4" /> Chiudi set
|
||||||
</button>
|
</button>
|
||||||
@@ -513,7 +513,7 @@ function ScoutBoard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={finePartita}
|
onClick={finePartita}
|
||||||
disabled={salvaMatch.isPending}
|
disabled={salvaMatch.isPending}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" /> Fine partita
|
<Save className="h-4 w-4" /> Fine partita
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+73
-56
@@ -1,11 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
|
import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
|
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
|
||||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import { formatData, inizialiDa } from "@/lib/crapp-data";
|
import { formatData, inizialiDa } from "@/lib/crapp-data";
|
||||||
|
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||||
import {
|
import {
|
||||||
useRosa,
|
useRosa,
|
||||||
@@ -50,7 +51,7 @@ import {
|
|||||||
|
|
||||||
function RuoloBadge({ ruolo }: { ruolo: string }) {
|
function RuoloBadge({ ruolo }: { ruolo: string }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-accent-foreground">
|
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide text-accent-foreground">
|
||||||
{ruolo}
|
{ruolo}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -146,7 +147,7 @@ function Squadra() {
|
|||||||
className="h-11 w-11 text-lg"
|
className="h-11 w-11 text-lg"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-bold leading-tight">
|
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||||
{nomeCompleto(a)}
|
{nomeCompleto(a)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-2">
|
<span className="mt-1 flex items-center gap-2">
|
||||||
@@ -163,7 +164,7 @@ function Squadra() {
|
|||||||
|
|
||||||
{isOpen && a.nascita ? (
|
{isOpen && a.nascita ? (
|
||||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,10 +192,12 @@ function Squadra() {
|
|||||||
className="h-11 w-11 text-lg"
|
className="h-11 w-11 text-lg"
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||||
|
{g.nome}
|
||||||
|
</span>
|
||||||
<span className="mt-1 flex items-center gap-2">
|
<span className="mt-1 flex items-center gap-2">
|
||||||
<RuoloBadge ruolo={g.ruolo} />
|
<RuoloBadge ruolo={g.ruolo} />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-[13px] text-muted-foreground">
|
||||||
{g.numero ? `#${g.numero}` : "n° da definire"}
|
{g.numero ? `#${g.numero}` : "n° da definire"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -229,7 +232,7 @@ function Squadra() {
|
|||||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||||
{g.nascita ? (
|
{g.nascita ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||||
{g.nascita.slice(0, 4)}
|
{g.nascita.slice(0, 4)}
|
||||||
</p>
|
</p>
|
||||||
@@ -243,13 +246,11 @@ function Squadra() {
|
|||||||
{ l: "Presenze di fila", v: g.streak },
|
{ l: "Presenze di fila", v: g.streak },
|
||||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||||
{ l: "MVP", v: g.mvp },
|
{ l: "MVP", v: g.mvp },
|
||||||
...(sonoAllenatore
|
...(sonoAllenatore ? [] : [{ l: "Cacche 💩", v: g.cacchePartita || "—" }]),
|
||||||
? []
|
|
||||||
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
|
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
<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="font-display text-xl leading-none">{s.v}</p>
|
||||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||||
{s.l}
|
{s.l}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -258,11 +259,11 @@ function Squadra() {
|
|||||||
|
|
||||||
{sonoAllenatore ? null : (
|
{sonoAllenatore ? null : (
|
||||||
<>
|
<>
|
||||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||||
</p>
|
</p>
|
||||||
{sbloccati.length === 0 ? (
|
{sbloccati.length === 0 ? (
|
||||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-[13px] text-muted-foreground">
|
||||||
Nessun badge sbloccato per ora.
|
Nessun badge sbloccato per ora.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -274,17 +275,20 @@ function Squadra() {
|
|||||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||||
<p className="mt-1 text-xs font-bold leading-tight">
|
<p className="mt-1 text-[13px] font-bold leading-tight">
|
||||||
{b.def.nome}
|
{b.def.nome}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{b.valore} {b.def.unita}
|
{b.valore} {b.def.unita}
|
||||||
{b.prossimaSoglia
|
{b.prossimaSoglia
|
||||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||||
: ""}
|
: ""}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
|
className={cn(
|
||||||
|
"mt-1.5 text-[13px] font-bold uppercase",
|
||||||
|
meta.text,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</p>
|
</p>
|
||||||
@@ -326,8 +330,8 @@ function Squadra() {
|
|||||||
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
|
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 />
|
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
|
||||||
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
|
<span className="shrink-0 text-[15px] text-foreground/80">Classifica per</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-right text-[14px] font-bold">
|
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
|
||||||
{criteri.find((c) => c.id === criterio)?.label}
|
{criteri.find((c) => c.id === criterio)?.label}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -345,7 +349,7 @@ function Squadra() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCriterio(c.id)}
|
onClick={() => setCriterio(c.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
|
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-[15px] font-bold",
|
||||||
criterio === c.id
|
criterio === c.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-foreground",
|
: "bg-secondary text-foreground",
|
||||||
@@ -368,13 +372,13 @@ function Squadra() {
|
|||||||
{rank[i]}
|
{rank[i]}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-bold">
|
<p className="truncate text-[15px] font-bold">
|
||||||
{g.nome}
|
{g.nome}
|
||||||
{rank[i] === 1 ? (
|
{rank[i] === 1 ? (
|
||||||
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<p className="truncate text-[13px] text-muted-foreground">
|
||||||
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,14 +418,14 @@ function Squadra() {
|
|||||||
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
<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 className="flex items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||||
Progresso collettivo
|
Progresso collettivo
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-4xl leading-none">
|
<p className="font-display text-4xl leading-none">
|
||||||
<Numero valore={mediaObiettivi} suffisso="%" />
|
<Numero valore={mediaObiettivi} suffisso="%" />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-primary-foreground/70">
|
<p className="text-[13px] text-primary-foreground/70">
|
||||||
{completati}/{obiettivi.length} completati
|
{completati}/{obiettivi.length} completati
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,38 +436,47 @@ function Squadra() {
|
|||||||
const pct = progressoObiettivo(o);
|
const pct = progressoObiettivo(o);
|
||||||
const fatto = pct >= 100;
|
const fatto = pct >= 100;
|
||||||
return (
|
return (
|
||||||
<Reveal
|
<Reveal key={o.id} indice={i}>
|
||||||
key={o.id}
|
<ObiettivoDrawer obiettivo={o}>
|
||||||
indice={i}
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
|
||||||
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(
|
className={cn(
|
||||||
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
|
"premi block w-full rounded-3xl bg-card p-4 text-left shadow-card ring-1",
|
||||||
fatto
|
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||||
? "bg-success text-success-foreground"
|
|
||||||
: "bg-secondary text-muted-foreground",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{fatto ? "Completato" : `${pct}%`}
|
<div className="flex items-start gap-2">
|
||||||
</span>
|
<span className="text-lg leading-none">{o.emoji}</span>
|
||||||
</div>
|
<div className="min-w-0 flex-1">
|
||||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
<p className="text-[15px] font-bold leading-tight">{o.titolo}</p>
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">{o.descrizione}</p>
|
||||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
</div>
|
||||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
<span
|
||||||
</p>
|
className={cn(
|
||||||
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
"rounded-full px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
fatto
|
||||||
|
? "bg-success text-success-foreground"
|
||||||
|
: "bg-secondary text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fatto ? "Completato" : `${pct}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||||
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||||
|
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||||
|
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[13px] font-semibold text-accent">
|
||||||
|
{microcopyObiettivo(o)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[13px] text-muted-foreground">{o.impatto}</p>
|
||||||
|
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
|
||||||
|
Come funziona <ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</ObiettivoDrawer>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -483,8 +496,10 @@ function Squadra() {
|
|||||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-5 w-5 text-accent" />
|
<Icon className="h-5 w-5 text-accent" />
|
||||||
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
<p className="text-[15px] font-bold leading-tight">{b.nome}</p>
|
||||||
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
|
<p className="ml-auto text-[13px] text-muted-foreground">
|
||||||
|
{descrizioneSoglie(b)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||||
{gradiOrdine.map((grado) => {
|
{gradiOrdine.map((grado) => {
|
||||||
@@ -500,9 +515,11 @@ function Squadra() {
|
|||||||
key={grado}
|
key={grado}
|
||||||
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
||||||
>
|
>
|
||||||
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
|
<p className={cn("text-[13px] font-bold uppercase", meta.text)}>
|
||||||
|
{meta.label}
|
||||||
|
</p>
|
||||||
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{quanti}/{rosa.length}
|
{quanti}/{rosa.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
-- 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
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
|||||||
|
|
||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
| Cartella | Cosa verifica | Serve rete? |
|
| Cartella | Cosa verifica | Serve rete? |
|
||||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), 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ì |
|
| `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ì |
|
| `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 | — |
|
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||||
|
|
||||||
## Server di sviluppo con Node vecchio
|
## Server di sviluppo con Node vecchio
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
* Obiettivi di squadra end-to-end contro il database locale: `bun test/integration/obiettivi.test.ts`.
|
* 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
|
* 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
|
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
|
||||||
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
|
* su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle
|
||||||
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
||||||
*
|
*
|
||||||
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { giocatori } from "@/lib/crapp-data";
|
import { giocatori } from "@/lib/crapp-data";
|
||||||
import { obiettiviSquadra } from "@/lib/obiettivi";
|
import { obiettiviSquadra } from "@/lib/obiettivi";
|
||||||
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
import { serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
@@ -282,33 +282,51 @@ if (!locale) {
|
|||||||
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
||||||
async () => {
|
async () => {
|
||||||
const pizzataId = `${PREFISSO}-pizzata`;
|
const pizzataId = `${PREFISSO}-pizzata`;
|
||||||
const inserita = await rest("eventi_app", {
|
const pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
|
||||||
method: "POST",
|
// OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
|
||||||
body: JSON.stringify({
|
for (const [id, giorno] of [
|
||||||
id: pizzataId,
|
[pizzataId, "10"],
|
||||||
tipo: "evento",
|
[pizzataFuturaId, "20"],
|
||||||
titolo: "Test obiettivi o6",
|
] as const) {
|
||||||
data: `${MESE_TEST}-20`,
|
const inserita = await rest("eventi_app", {
|
||||||
}),
|
method: "POST",
|
||||||
});
|
body: JSON.stringify({
|
||||||
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
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 eventiReali = await leggiEventi();
|
const tutti = await leggiEventi();
|
||||||
const o6InMese = obiettiviSquadra(
|
const o6Di = (ids: string[], oggi: Date) =>
|
||||||
giocatori,
|
obiettiviSquadra(
|
||||||
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
giocatori,
|
||||||
OGGI,
|
{ eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
|
||||||
).find((o) => o.id === "o6")!;
|
oggi,
|
||||||
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
|
).find((o) => o.id === "o6")!;
|
||||||
|
|
||||||
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(
|
assert.equal(
|
||||||
o6MeseDopo.valore,
|
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,
|
||||||
0,
|
0,
|
||||||
"lo stesso evento non conta più il mese successivo (si azzera)",
|
"lo stesso evento non conta più il mese successivo (si azzera)",
|
||||||
);
|
);
|
||||||
@@ -386,68 +404,129 @@ if (!locale) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
|
await prova(
|
||||||
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
|
"o7 percentuale di presenze collettive da eventi/risposte reali del database",
|
||||||
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
|
async () => {
|
||||||
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
|
// o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
|
||||||
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
|
// convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
|
||||||
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||||
const partitaId = `${PREFISSO}-o7-partita`;
|
const partitaId = `${PREFISSO}-o7-partita`;
|
||||||
const OGGI_STR = "2099-01-20";
|
const [g1, g2, g3] = giocatori;
|
||||||
|
|
||||||
for (const [id, tipo, data] of [
|
for (const [id, tipo, data] of [
|
||||||
[allenamentoId, "allenamento", "2099-01-05"],
|
[allenamentoId, "allenamento", "2099-01-05"],
|
||||||
[partitaId, "partita", "2099-01-08"],
|
[partitaId, "partita", "2099-01-08"],
|
||||||
] as const) {
|
] as const) {
|
||||||
const inserito = await rest("eventi_app", {
|
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", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
|
body: JSON.stringify(righe),
|
||||||
});
|
});
|
||||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||||
}
|
|
||||||
|
|
||||||
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
// il ritardo conta). g3: assente a entrambi (0 presenze).
|
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||||
const [g1, g2, g3] = giocatori;
|
);
|
||||||
const righe = [
|
const presenzeReali = {
|
||||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
...(await leggiPresenze(allenamentoId)),
|
||||||
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
...(await leggiPresenze(partitaId)),
|
||||||
{ 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 eventiReali = (await leggiEventi()).filter(
|
const o7 = obiettiviSquadra([g1!, g2!, g3!], {
|
||||||
(e) => e.id === allenamentoId || e.id === partitaId,
|
eventi: eventiReali,
|
||||||
);
|
presenze: presenzeReali,
|
||||||
const presenzeReali = {
|
pagelle: [],
|
||||||
...(await leggiPresenze(allenamentoId)),
|
}).find((o) => o.id === "o7")!;
|
||||||
...(await leggiPresenze(partitaId)),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
|
assert.equal(
|
||||||
...g,
|
o7.valore,
|
||||||
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
|
67,
|
||||||
}));
|
"4 presenze su 6 posti (il ritardo conta), calcolate dal database",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
|
await prova(
|
||||||
eventi: [],
|
"o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no",
|
||||||
presenze: {},
|
async () => {
|
||||||
pagelle: [],
|
// Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa:
|
||||||
}).find((o) => o.id === "o7")!;
|
// 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()}`);
|
||||||
|
|
||||||
assert.equal(
|
const ids = [allenamentoId, cenaId];
|
||||||
o7.valore,
|
const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id));
|
||||||
4,
|
const presenzeReali = {
|
||||||
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
|
...(await leggiPresenze(allenamentoId)),
|
||||||
);
|
...(await leggiPresenze(cenaId)),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const o1 = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||||
|
OGGI,
|
||||||
|
).find((o) => o.id === "o1")!;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
o1.valore,
|
||||||
|
67,
|
||||||
|
"2 presenti (presente e ritardo) su 3 convocati; g4 non convocato e la cena non contano",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
||||||
const matchId = `${PREFISSO}-o12-m1`;
|
const matchId = `${PREFISSO}-o12-m1`;
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** Check del componente degli avvisi in Home: `bun test/unit/avviso.test.ts`. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { CalendarClock, FileX } from "lucide-react";
|
||||||
|
import { CorpoAvviso, type TonoAvviso } from "@/components/crapp/Avviso";
|
||||||
|
|
||||||
|
const html = (p: Parameters<typeof CorpoAvviso>[0]) =>
|
||||||
|
renderToStaticMarkup(createElement(CorpoAvviso, p));
|
||||||
|
|
||||||
|
// --- ogni tono ha il suo colore, e l'icona è decorativa (il titolo dice già tutto) ---
|
||||||
|
const classi: Record<TonoAvviso, string> = {
|
||||||
|
critico: "bg-destructive",
|
||||||
|
attenzione: "bg-warning",
|
||||||
|
scaduto: "bg-primary",
|
||||||
|
azione: "bg-accent-grad",
|
||||||
|
};
|
||||||
|
for (const [tono, classe] of Object.entries(classi) as [TonoAvviso, string][]) {
|
||||||
|
const h = html({ tono, icona: FileX, titolo: "Titolo" });
|
||||||
|
assert.ok(h.includes(classe), `${tono}: classe colore ${classe}`);
|
||||||
|
assert.ok(h.includes("Titolo"), `${tono}: titolo`);
|
||||||
|
assert.ok(h.includes('aria-hidden="true"'), `${tono}: icona nascosta ai lettori di schermo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- testi: un paragrafo per frase ---------------------------------------------------
|
||||||
|
const conTesti = html({
|
||||||
|
tono: "attenzione",
|
||||||
|
icona: CalendarClock,
|
||||||
|
titolo: "Certificato in scadenza",
|
||||||
|
testi: ["Prima frase.", "Seconda frase."],
|
||||||
|
});
|
||||||
|
assert.equal((conTesti.match(/<p /g) ?? []).length, 3, "titolo + due paragrafi");
|
||||||
|
assert.ok(!conTesti.includes("<ul"), "senza elenco niente lista");
|
||||||
|
|
||||||
|
// --- elenco: righe chi/cosa, conteggio solo con più di una riga ----------------------
|
||||||
|
const una = html({
|
||||||
|
tono: "critico",
|
||||||
|
icona: FileX,
|
||||||
|
titolo: "Certificati mancanti",
|
||||||
|
elenco: [{ chi: "Mario Rossi", cosa: "non caricato" }],
|
||||||
|
});
|
||||||
|
assert.ok(una.includes("Mario Rossi") && una.includes("non caricato"), "riga con chi e cosa");
|
||||||
|
assert.ok(!una.includes("persone"), "una sola riga: nessun conteggio");
|
||||||
|
|
||||||
|
const tre = html({
|
||||||
|
tono: "critico",
|
||||||
|
icona: FileX,
|
||||||
|
titolo: "Certificati mancanti",
|
||||||
|
elenco: [
|
||||||
|
{ chi: "Mario Rossi", cosa: "non caricato" },
|
||||||
|
{ chi: "Luca Bianchi", cosa: "non caricato" },
|
||||||
|
{ chi: "Anna Verdi", cosa: "non caricato" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal((tre.match(/<li /g) ?? []).length, 3, "una riga per persona");
|
||||||
|
assert.ok(tre.includes('aria-label="3 persone"'), "conteggio accessibile");
|
||||||
|
|
||||||
|
// --- freccia solo se la card porta altrove -------------------------------------------
|
||||||
|
const base = { tono: "scaduto", icona: FileX, titolo: "t" } as const;
|
||||||
|
assert.ok(!html(base).includes("lucide-chevron-right"), "senza link niente freccia");
|
||||||
|
assert.ok(
|
||||||
|
html({ ...base, cliccabile: true }).includes("lucide-chevron-right"),
|
||||||
|
"con link la freccia",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- etichetta e riquadri (turno palloni) --------------------------------------------
|
||||||
|
const palloni = html({
|
||||||
|
tono: "azione",
|
||||||
|
icona: FileX,
|
||||||
|
titolo: "Turno palloni",
|
||||||
|
etichetta: "Oggi",
|
||||||
|
testi: ["Porta i palloni."],
|
||||||
|
riquadri: true,
|
||||||
|
});
|
||||||
|
assert.ok(palloni.includes("Oggi"), "etichetta mostrata");
|
||||||
|
assert.ok(palloni.includes("bg-black/15"), "il messaggio è in un riquadro");
|
||||||
|
assert.ok(palloni.includes("bg-black/25"), "etichetta in pillola");
|
||||||
|
assert.ok(!html({ ...base }).includes("bg-black/25"), "senza etichetta niente pillola");
|
||||||
|
assert.ok(
|
||||||
|
!html({ ...base, testi: ["x"] }).includes("bg-black/15"),
|
||||||
|
"senza riquadri testo semplice",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("avviso: ok");
|
||||||
@@ -69,6 +69,7 @@ const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
|
|||||||
unita: "%",
|
unita: "%",
|
||||||
emoji: "🎯",
|
emoji: "🎯",
|
||||||
impatto: "",
|
impatto: "",
|
||||||
|
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
|
||||||
});
|
});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")),
|
ids(g(), [], [obiettivo("o1", 95), obiettivo("o2", 92)]).filter((i) => i.startsWith("obiettivo")),
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const tipiNuovi: TipoNotifica[] = [
|
|||||||
"sollecita_presenze_24h",
|
"sollecita_presenze_24h",
|
||||||
"sollecita_presenze_12h",
|
"sollecita_presenze_12h",
|
||||||
"sollecita_presenze_6h",
|
"sollecita_presenze_6h",
|
||||||
|
"compleanno",
|
||||||
|
"compleanno_auguri",
|
||||||
];
|
];
|
||||||
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||||
for (const tipo of tipiNuovi) {
|
for (const tipo of tipiNuovi) {
|
||||||
@@ -67,6 +69,13 @@ for (const tipo of tipiNuovi) {
|
|||||||
assert.equal(n.corpo, corpoMultiriga, `${tipo}: il corpo a più righe arriva intatto`);
|
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
|
// 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.
|
// `whitespace-pre-line` il testo a più righe diventerebbe un paragrafo unico.
|
||||||
const uiBits = readFileSync(
|
const uiBits = readFileSync(
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/** Check della card di dettaglio degli obiettivi: `bun test/unit/obiettivi-dettaglio.test.ts`. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { SchedaObiettivo } from "@/components/crapp/ObiettivoDrawer";
|
||||||
|
import { Drawer } from "@/components/ui/drawer";
|
||||||
|
import { giocatori } from "@/lib/crapp-data";
|
||||||
|
import { contestoVuoto, obiettiviSquadra, type ObiettivoSquadra } from "@/lib/obiettivi";
|
||||||
|
|
||||||
|
const OGGI = new Date("2026-08-15T10:00:00Z");
|
||||||
|
const tutti = obiettiviSquadra(giocatori, contestoVuoto, OGGI);
|
||||||
|
|
||||||
|
/** Rende la scheda dentro un Drawer aperto, come fa l'app (titolo e descrizione ne hanno bisogno). */
|
||||||
|
const scheda = (o: ObiettivoSquadra) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(Drawer, { open: true }, createElement(SchedaObiettivo, { o })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- ogni obiettivo ha tutti i testi, nessun campo vuoto ----------------------------
|
||||||
|
const ids = tutti.map((o) => o.id).sort();
|
||||||
|
assert.deepEqual(
|
||||||
|
ids,
|
||||||
|
["o1", "o11", "o12", "o13", "o2", "o3", "o4", "o5", "o6", "o7"],
|
||||||
|
"gli obiettivi sono quelli attesi: se ne aggiungi uno, scrivi il suo dettaglio",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const o of tutti) {
|
||||||
|
const d = o.dettaglio;
|
||||||
|
for (const [nome, testo] of Object.entries({
|
||||||
|
comeSiCalcola: d.comeSiCalcola,
|
||||||
|
periodo: d.periodo,
|
||||||
|
fonte: d.fonte,
|
||||||
|
esempio: d.esempio,
|
||||||
|
})) {
|
||||||
|
assert.ok(testo.trim().length > 20, `${o.id}: «${nome}» troppo corto o vuoto`);
|
||||||
|
}
|
||||||
|
for (const voce of [...d.conta, ...d.nonConta, ...(d.perse ?? [])]) {
|
||||||
|
assert.ok(voce.trim().length > 10, `${o.id}: voce di elenco vuota o troppo corta`);
|
||||||
|
}
|
||||||
|
assert.equal(new Set(d.conta).size, d.conta.length, `${o.id}: voci «conta» duplicate`);
|
||||||
|
assert.equal(new Set(d.nonConta).size, d.nonConta.length, `${o.id}: voci «non conta» duplicate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- «presenza persa» solo sulle due percentuali di presenze ------------------------
|
||||||
|
for (const o of tutti) {
|
||||||
|
const haPerse = Boolean(o.dettaglio.perse && o.dettaglio.perse.length > 0);
|
||||||
|
assert.equal(haPerse, o.id === "o1" || o.id === "o7", `${o.id}: presenza persa solo su o1 e o7`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- le due presenze spiegano gli stessi punti chiave -------------------------------
|
||||||
|
for (const id of ["o1", "o7"]) {
|
||||||
|
const d = tutti.find((o) => o.id === id)!.dettaglio;
|
||||||
|
const tutto = [d.comeSiCalcola, ...d.conta, ...(d.perse ?? []), ...d.nonConta].join(" ");
|
||||||
|
for (const parola of ["convocat", "presente", "in ritardo", "assente", "cene"]) {
|
||||||
|
assert.ok(tutto.toLowerCase().includes(parola), `${id}: la spiegazione non cita «${parola}»`);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
d.nonConta.some((v) => /non è convocato/i.test(v)),
|
||||||
|
`${id}: dice che il non convocato non pesa`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mese = tutti.find((o) => o.id === "o1")!.dettaglio;
|
||||||
|
const totale = tutti.find((o) => o.id === "o7")!.dettaglio;
|
||||||
|
assert.ok(/mese/i.test(mese.periodo) && /azzera/i.test(mese.periodo), "o1: si azzera ogni mese");
|
||||||
|
assert.ok(/stagione/i.test(totale.periodo) && /mai/i.test(totale.periodo), "o7: non si azzera mai");
|
||||||
|
|
||||||
|
// --- la card renderizzata mostra tutte le sezioni ------------------------------------
|
||||||
|
const o1 = tutti.find((o) => o.id === "o1")!;
|
||||||
|
const html1 = scheda(o1);
|
||||||
|
for (const titolo of [
|
||||||
|
"Stato attuale",
|
||||||
|
"Come si calcola",
|
||||||
|
"Cosa conta",
|
||||||
|
"Presenza persa",
|
||||||
|
"Non entra nel calcolo",
|
||||||
|
"Quando vale",
|
||||||
|
"Da dove arrivano i dati",
|
||||||
|
"Un esempio",
|
||||||
|
]) {
|
||||||
|
assert.ok(html1.includes(titolo), `la card di o1 non mostra «${titolo}»`);
|
||||||
|
}
|
||||||
|
assert.ok(html1.includes(o1.titolo.replace(/&/g, "&")), "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");
|
||||||
+335
-17
@@ -100,6 +100,169 @@ const soloCompleanni: ContestoObiettivi = {
|
|||||||
};
|
};
|
||||||
assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2").valore, 0);
|
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 ------
|
// --- 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.
|
// Stesso evento/presenze: "in mese" a settembre, "fuori mese" se letto da agosto.
|
||||||
@@ -216,7 +379,7 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
|
|
||||||
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
|
// --- o6: evento di squadra al mese, si azzera come o1 ------------------------
|
||||||
{
|
{
|
||||||
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
|
const OGGI_SETTEMBRE = new Date("2026-09-20T10:00:00Z");
|
||||||
const pizzataSettembre: ContestoObiettivi = {
|
const pizzataSettembre: ContestoObiettivi = {
|
||||||
eventi: [evento("p1", "2026-09-12", "evento")],
|
eventi: [evento("p1", "2026-09-12", "evento")],
|
||||||
presenze: {},
|
presenze: {},
|
||||||
@@ -233,6 +396,85 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
"lo stesso evento non conta se oggi è agosto",
|
"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 = {
|
const allenamentoNelMese: ContestoObiettivi = {
|
||||||
eventi: [evento("al1", "2026-08-12", "allenamento")],
|
eventi: [evento("al1", "2026-08-12", "allenamento")],
|
||||||
presenze: {},
|
presenze: {},
|
||||||
@@ -245,23 +487,85 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- somme sulla rosa --------------------------------------------------------
|
// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) ---
|
||||||
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
|
{
|
||||||
assert.equal(trova(vuoti, "o7").valore, sommaPresenze);
|
const [g1, g2, g3, g4] = giocatori;
|
||||||
assert.equal(
|
const rosa4 = [g1!, g2!, g3!, g4!];
|
||||||
trova(obiettiviSquadra([], contestoVuoto), "o7").valore,
|
const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7");
|
||||||
0,
|
|
||||||
"rosa vuota: nessuna presenza",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula
|
assert.equal(o7(contestoVuoto).titolo, "Presenze collettive");
|
||||||
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30).
|
assert.equal(o7(contestoVuoto).target, 90);
|
||||||
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! }));
|
assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN");
|
||||||
assert.equal(
|
assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0");
|
||||||
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
|
|
||||||
30,
|
// Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%.
|
||||||
"somma di presenze note, indipendente dal roster reale",
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
const pagelle: VotoPagella[] = [
|
const pagelle: VotoPagella[] = [
|
||||||
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
{ match_id: "m1", votante_id: "g1", votato_id: "g2", voto: 7 },
|
||||||
@@ -388,6 +692,7 @@ const o = (valore: number, target: number): ObiettivoSquadra => ({
|
|||||||
unita: "%",
|
unita: "%",
|
||||||
emoji: "🎯",
|
emoji: "🎯",
|
||||||
impatto: "i",
|
impatto: "i",
|
||||||
|
dettaglio: { comeSiCalcola: "", conta: [], nonConta: [], periodo: "", fonte: "", esempio: "" },
|
||||||
});
|
});
|
||||||
assert.equal(progressoObiettivo(o(0, 10)), 0);
|
assert.equal(progressoObiettivo(o(0, 10)), 0);
|
||||||
assert.equal(progressoObiettivo(o(5, 10)), 50);
|
assert.equal(progressoObiettivo(o(5, 10)), 50);
|
||||||
@@ -417,4 +722,17 @@ assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.")
|
|||||||
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
|
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
|
||||||
assert.equal(microcopyObiettivo(o(0, 100)), "Tocca a noi far partire questo obiettivo.");
|
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");
|
console.log("obiettivi: ok");
|
||||||
@@ -262,15 +262,31 @@ assert.deepEqual(
|
|||||||
["g1"],
|
["g1"],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
const senzaCertificato = avvisiCertificati(
|
||||||
avvisiCertificati(
|
[{ ...squadra[0]!, id: "g6" }],
|
||||||
[{ ...squadra[0]!, id: "g6" }],
|
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
||||||
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
oggiTest,
|
||||||
oggiTest,
|
|
||||||
).inScadenza.length,
|
|
||||||
0,
|
|
||||||
"certificato mancante: nessun avviso",
|
|
||||||
);
|
);
|
||||||
|
assert.equal(senzaCertificato.inScadenza.length + senzaCertificato.scaduti.length, 0);
|
||||||
|
assert.deepEqual(
|
||||||
|
senzaCertificato.mancanti.map((m) => m.giocatoreId),
|
||||||
|
["g6"],
|
||||||
|
"certificato mancante (DD-046): in `mancanti`, non tra scadenze",
|
||||||
|
);
|
||||||
|
// DD-046: profilo assente e scadenza senza file valgono come mancante; fuori rosa e allenatori no
|
||||||
|
const mancantiRosa = avvisiCertificati(
|
||||||
|
[
|
||||||
|
...rosaCertificati,
|
||||||
|
{ ...squadra[0]!, id: "g7", nome: "Zeno", cognome: "Zeta", attivo: true, tipo: "giocatore" },
|
||||||
|
{ ...squadra[0]!, id: "g8", nome: "Aldo", cognome: "Alfa", attivo: true, tipo: "giocatore" },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
...profiliCertificati,
|
||||||
|
g8: { ...vuoto, giocatoreId: "g8", certificatoScadenza: "2026-12-01", certificatoPath: null },
|
||||||
|
},
|
||||||
|
oggiTest,
|
||||||
|
).mancanti.map((m) => m.giocatoreId);
|
||||||
|
assert.deepEqual(mancantiRosa, ["g8", "g7"], "g7 senza profilo, g8 senza file; ordine per cognome");
|
||||||
|
|
||||||
const dueInScadenza: Record<string, Profilo> = {
|
const dueInScadenza: Record<string, Profilo> = {
|
||||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
||||||
@@ -412,5 +428,24 @@ const soloGiocatore = avvisiCertificatiUtente(rosaUtente, profiliUtente, oggiTes
|
|||||||
});
|
});
|
||||||
assert.equal(soloGiocatore.personale?.giocatoreId, "g1");
|
assert.equal(soloGiocatore.personale?.giocatoreId, "g1");
|
||||||
assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff");
|
assert.equal(soloGiocatore.staff, null, "il giocatore semplice non vede lo staff");
|
||||||
|
assert.equal(soloGiocatore.personaleMancante, false);
|
||||||
|
|
||||||
|
// DD-046: chi non ha caricato il certificato lo vede nel personale; l'admin lo vede anche per gli altri
|
||||||
|
const adminSenzaFile = avvisiCertificatiUtente(rosaUtente, {}, oggiTest, {
|
||||||
|
admin: true,
|
||||||
|
base: rosaUtente[0]!,
|
||||||
|
});
|
||||||
|
assert.equal(adminSenzaFile.personaleMancante, true);
|
||||||
|
assert.equal(adminSenzaFile.personale, undefined);
|
||||||
|
assert.deepEqual(
|
||||||
|
adminSenzaFile.staff?.mancanti.map((m) => m.giocatoreId),
|
||||||
|
["g2"],
|
||||||
|
"lo staff non ripete il suo nome tra i mancanti",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
avvisiCertificatiUtente(rosaUtente, {}, oggiTest, { admin: true, base: null }).personaleMancante,
|
||||||
|
false,
|
||||||
|
"admin non giocatore: nessun avviso personale",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("profili-core: ok");
|
console.log("profili-core: ok");
|
||||||
@@ -57,6 +57,12 @@ const casi: Array<{ data: Evento["data"]; titolo: string; testo: string }> = [
|
|||||||
titolo: "🏐 Palloni",
|
titolo: "🏐 Palloni",
|
||||||
testo: "Sabato tocca a te.",
|
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: null, titolo: "CrAPP", testo: "Apri l'app per i dettagli." },
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in new issue
Block a user