Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49351e536 |
No files matched your search
+10
-12
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 06/10/2026
|
Ultimo aggiornamento: 01/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,20 +9,17 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
|||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
- **Versione:** 1.4.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — gli eventi con convocati si vedono
|
- **Versione:** 1.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
|
||||||
solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0: avviso «Presenza modificata» ad admin e
|
(3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
|
||||||
allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario mostra solo i prossimi
|
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
|
||||||
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
|
|
||||||
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
|
|
||||||
ridisegnati e i caratteri più grandi. 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:** 44 file in `supabase/migrations/`, fino a `m33_presenza_modificata_allenatori`. Tutte applicate in produzione (verificato con `npx supabase migration list` il
|
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
|
||||||
09/10/2026; la `m32` è l'avviso «Presenza modificata» ai soli admin, DD-050). La `m33`, che lo estende agli allenatori, è stata applicata il 09/10/2026. La `m29` toglie
|
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
|
||||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
`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 `main` dalla 1.3.0.
|
è su `develop`, non ancora su `main`.
|
||||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
- **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).
|
||||||
@@ -34,8 +31,9 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
|||||||
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
|
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
|
||||||
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
||||||
slot lo occupa anche in produzione e va liberato da un admin.
|
slot lo occupa anche in produzione e va liberato da un admin.
|
||||||
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
|
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99
|
||||||
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
|
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
|
||||||
|
presenti.
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
|
|||||||
@@ -85,13 +85,9 @@ 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`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||||
schermate, pochi click.
|
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
|
||||||
|
|||||||
+1
-99
@@ -13,113 +13,15 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
## [1.4.1] - 2026-10-19
|
|
||||||
|
|
||||||
Gli eventi con dei convocati si vedono solo a loro, e «Rosa e presenze» elenca solo i convocati.
|
|
||||||
Nessuna migration.
|
|
||||||
|
|
||||||
### Modificato
|
|
||||||
|
|
||||||
- Un evento con dei convocati compare nel Calendario, in Home e nell'elenco eventi solo ai
|
|
||||||
convocati. Admin e allenatori lo vedono sempre; `convocati` vuoto = visibile a tutti. È un
|
|
||||||
filtro dell'interfaccia: i dati restano leggibili dal database (RLS di `eventi_app` invariata).
|
|
||||||
|
|
||||||
### Corretto
|
|
||||||
|
|
||||||
- «Rosa e presenze» di allenamenti, partite ed eventi mostra solo i convocati (prima elencava
|
|
||||||
sempre tutta la rosa, anche se ne era stato convocato uno solo). Il conteggio «Hanno risposto»,
|
|
||||||
il pulsante «Sollecita» e il riquadro «La tua risposta» seguono la stessa regola; `convocati`
|
|
||||||
vuoto resta «tutta la rosa».
|
|
||||||
- La card dell'evento (Calendario e Home) conta «presenti/convocati» invece di dividere per tutta la
|
|
||||||
rosa, e i pulsanti di presenza compaiono solo ai convocati: un admin che è anche giocatore ma non
|
|
||||||
è convocato vede l'evento senza poter rispondere. In «Da confermare» (Home) non finiscono più gli
|
|
||||||
eventi a cui non sei convocato.
|
|
||||||
|
|
||||||
## [1.4.0] - 2026-10-09
|
|
||||||
|
|
||||||
Gli admin e gli allenatori vengono avvisati quando una presenza cambia a ridosso di una partita o di un allenamento.
|
|
||||||
Due migration, `m32` e `m33`, entrambe applicate in produzione il 09/10/2026.
|
|
||||||
|
|
||||||
### Aggiunto
|
|
||||||
|
|
||||||
- **Notifica «Presenza modificata» (DD-050)** — quando una risposta a una partita o a un allenamento cambia,
|
|
||||||
compare o viene ritirata a meno di 6 ore dall'inizio, gli admin e gli allenatori ricevono una notifica (centro
|
|
||||||
notifiche, push, email) con chi, quale evento e lo stato prima e dopo. Migration `m32` (solo admin, applicata in
|
|
||||||
produzione il 09/10/2026) e `m33` (aggiunge gli allenatori, applicata il 09/10/2026).
|
|
||||||
|
|
||||||
## [1.3.1] - 2026-10-06
|
|
||||||
|
|
||||||
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
|
|
||||||
«Conferme 24h» con i due badge che ne dipendevano. Nessuna migration da applicare.
|
|
||||||
|
|
||||||
### Modificato
|
|
||||||
|
|
||||||
- **Calendario: «Prossimi compleanni»** — la sezione in fondo mostra solo i compleanni del mese in corso non
|
|
||||||
ancora passati, non più quelli del mese selezionato nella griglia; se nel mese non ce ne sono, lo dice
|
|
||||||
(«Nessun compleanno questo mese»).
|
|
||||||
|
|
||||||
### Rimosso
|
|
||||||
|
|
||||||
- **Serie «Conferme 24h» e badge collegati** — la serie non compare più nel profilo, in Home né nelle notifiche di
|
|
||||||
traguardo; restano Allenamenti e Partite. Rimossi anche il badge «Risposta lampo» e il segreto «Mai un forfait», che
|
|
||||||
si basavano su di essa. Nessuna migrazione: la colonna `risposto_il` resta, inutilizzata.
|
|
||||||
|
|
||||||
## [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
|
## [1.2.3] - 2026-10-04
|
||||||
|
|
||||||
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
|
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration.
|
||||||
|
|
||||||
### Modificato
|
### Modificato
|
||||||
|
|
||||||
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
|
- **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).
|
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
|
||||||
|
|||||||
+13
-16
@@ -22,7 +22,6 @@ sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011
|
|||||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
| `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) |
|
||||||
@@ -59,7 +58,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
|||||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
|
| `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` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
|
| `numero_tessera`, `data_tessera` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include tra i campi bloccati per chi reclama il proprio slot. |
|
||||||
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
|
| `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. |
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
|||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
||||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: oggi nessuna statistica lo usa (la serie «Conferme 24h» è stata rimossa il 2026-10-06). `aggiornato_il` è l'ultima modifica. |
|
| `risposte_presenze` | Risposte dei giocatori agli eventi. | `risposto_il` è l'istante della **prima** risposta e un trigger lo rende immutabile: confrontato con `eventi_app.creato_il` dà la serie «Conferme 24h». `aggiornato_il` è l'ultima modifica. |
|
||||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
||||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||||
@@ -122,11 +121,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
|||||||
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
- 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 `presenza_modificata` (M32, DD-050), `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
- Il `CHECK` sul tipo ammette anche `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
||||||
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
`sollecita_presenze_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 (compleanni e `presenza_modificata` compresi) ma non quelli dei pulsanti. `presenza_modificata` ha `evento_id` NULL (come `admin`): la UNIQUE non si applica e ogni modifica resta una riga (M32, DD-050).
|
automatici ma non quelli dei pulsanti.
|
||||||
|
|
||||||
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
||||||
|
|
||||||
@@ -152,17 +151,15 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
|
|||||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
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) |
|
||||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||||
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
|
| `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
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ il campionato. Dove serve il dettaglio c'è il rimando al modulo.
|
|||||||
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
|
| **Evento** | Riga di `eventi_app`: `partita`, `allenamento` o `evento` extra-campo (es. cena). I compleanni compaiono in calendario ma non sono eventi di campo. |
|
||||||
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
|
| **Convocati** | Giocatori chiamati a un evento. **`convocati` vuoto = tutta la rosa attiva**: è la convenzione di `convocatiEvento()` e di `evento_permette_voto()`. |
|
||||||
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
|
| **Presenza / risposta** | Risposta di un giocatore a un evento (`risposte_presenze`): `presente`, `ritardo`, `assente`, `forse`, `infortunato`. Vedi [modules/presenze.md](modules/presenze.md). |
|
||||||
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti e partite). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
|
| **Serie** | Streak di comportamenti positivi consecutivi (allenamenti, partite, conferme entro 24 ore). Vedi [modules/serie-presenze.md](modules/serie-presenze.md). |
|
||||||
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
|
| **Pagelle** | Voti 1-10 anonimi ai compagni dopo una partita; solo i convocati votano, finché `pagelle_chiuse = false` (DD-027). Vedi [modules/pagelle.md](modules/pagelle.md). |
|
||||||
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
|
| **MVP** | Voto al migliore in campo a fine partita, uno per votante (DD-027). Vedi [modules/mvp.md](modules/mvp.md). |
|
||||||
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
|
| **Badge** | Riconoscimenti calcolati a runtime, mai salvati (DD-007): normali (bronzo/argento/oro), segreti, social (votati dai compagni). Vedi [modules/badge.md](modules/badge.md). |
|
||||||
|
|||||||
+2
-7
@@ -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 ed export CSV; il tracciamento di numero e data
|
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||||
di tessera è stato tolto dalla dashboard (DD-048)
|
tesserato (numero e data di tessera)
|
||||||
- [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
|
||||||
@@ -47,11 +47,6 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
|||||||
- [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)
|
|
||||||
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
|
|
||||||
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
|
|
||||||
|
|
||||||
## 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,65 +0,0 @@
|
|||||||
# DD-047 — Notifica di compleanno
|
|
||||||
|
|
||||||
**Data:** 6 ottobre 2026
|
|
||||||
**Stato:** Accettata (migration `m31`, non ancora applicata in produzione; nasce dalla issue #11 «notifica»)
|
|
||||||
|
|
||||||
**Contesto**
|
|
||||||
I compleanni si vedono solo aprendo il Calendario, dove `compleanniEventi()` li calcola dalla data di
|
|
||||||
nascita pubblica (`giocatori_squadra.nascita`, DD-031). Nessun avviso arriva ad app chiusa. La issue #11
|
|
||||||
chiede una notifica del tipo «oggi è il compleanno di _tizio_».
|
|
||||||
|
|
||||||
**Decisione**
|
|
||||||
|
|
||||||
1. **Una notifica vera, automatica, su tutti e tre i canali** (DD-038): push, centro notifiche in-app ed
|
|
||||||
email. Nasce nel database come le altre notifiche automatiche (DD-040): un job `pg_cron` scrive in
|
|
||||||
`notifiche_utente`, il worker `mailer/` manda push ed email.
|
|
||||||
2. **Quando:** una volta al giorno, **dalle 8:00 (fuso `Europe/Rome`)**. Il job gira ogni ora e agisce solo
|
|
||||||
dalle 8 in poi; un registro ricorda a chi l'avviso del giorno è già stato generato, quindi un giro
|
|
||||||
mancato si recupera all'ora dopo e non si genera mai due volte.
|
|
||||||
3. **Chi compie gli anni** è chi ha `nascita` con lo stesso giorno e mese di oggi tra i membri **attivi**
|
|
||||||
della squadra (`giocatori_squadra.attivo`), allenatori compresi. Senza data di nascita o non attivo:
|
|
||||||
nessun avviso.
|
|
||||||
4. **Destinatari: tutti i membri attivi**, allenatori compresi (il compleanno è un fatto della squadra,
|
|
||||||
non una notifica di gioco: DD-039 non si applica). Due messaggi diversi, entrambi **senza l'età**:
|
|
||||||
- **il festeggiato** riceve **solo gli auguri**: titolo `Buon compleanno, <Nome>! 🎂`, **senza corpo**;
|
|
||||||
- **tutti gli altri** ricevono **solo l'avviso**: titolo `Oggi è il compleanno di <Nome>`, **senza corpo**.
|
|
||||||
5. **Più compleanni lo stesso giorno: un solo messaggio per destinatario.** Chi non compie gli anni riceve
|
|
||||||
un unico avviso con tutti i nomi: titolo `Oggi è il compleanno di <A> e <B>` (con tre o più: `A, B e C`,
|
|
||||||
in ordine alfabetico per nome), sempre senza corpo. Ogni festeggiato riceve **solo i propri auguri**,
|
|
||||||
senza i nomi degli altri.
|
|
||||||
6. **Registro.** Nuova tabella `compleanni_notificati (giocatore_id, giorno)`: una riga per destinatario e
|
|
||||||
giorno. Il registro, e non `notifiche_utente`, impedisce il doppione anche se il giocatore elimina la
|
|
||||||
notifica (stessa logica di DD-037). Il registro non è legato a un evento: i compleanni non sono
|
|
||||||
`eventi_app`.
|
|
||||||
7. **Tipi** (`CHECK` di `notifiche_utente`): `compleanno` (l'avviso agli altri) e `compleanno_auguri` (gli
|
|
||||||
auguri). Nessun pulsante manuale: la notifica parte solo dal job. Il trigger delle push accoda
|
|
||||||
entrambi; quello delle email li lascia passare, quindi arrivano anche per email.
|
|
||||||
8. **29 febbraio: per ora non gestito.** Chi è nato il 29 febbraio viene avvisato solo negli anni bisestili,
|
|
||||||
perché il confronto è tra giorno e mese. Negli altri anni non parte nulla (né il 28 febbraio né il 1°
|
|
||||||
marzo). È un limite noto, accettato: oggi non interessa nessuno in squadra.
|
|
||||||
|
|
||||||
**Alternative scartate**
|
|
||||||
|
|
||||||
- **Card in Home, senza push né migration** → più semplice, ma non avvisa chi non apre l'app: non è ciò che
|
|
||||||
chiede la issue.
|
|
||||||
- **Un messaggio per festeggiato anche a chi lo riceve da altri** → con due compleanni lo stesso giorno
|
|
||||||
sarebbero due notifiche e due mail al giorno per lo stesso destinatario.
|
|
||||||
- **Registro per `(destinatario, festeggiato, giorno)`** → serve solo a chi vuole due avvisi separati, che
|
|
||||||
abbiamo scartato; la chiave `(destinatario, giorno)` basta.
|
|
||||||
- **Allenatori esclusi** → scartata su indicazione esplicita: «tutti».
|
|
||||||
- **Gestire il 29 febbraio il 28 febbraio** → rimandato (punto 8).
|
|
||||||
|
|
||||||
**Conseguenze**
|
|
||||||
|
|
||||||
- Ogni giorno con un compleanno ogni membro attivo riceve una notifica e, se ha account, email e le email
|
|
||||||
accese, una mail. Con molti compleanni nello stesso periodo il tetto giornaliero di invio del worker
|
|
||||||
(`mailer/`) può farsi sentire: il compleanno non ha priorità sulle altre mail.
|
|
||||||
- Chi inserisce la propria data di nascita **il giorno stesso** del compleanno, dopo le 8 e dopo che gli
|
|
||||||
altri sono già stati avvisati, non genera un secondo avviso (il registro ha già il giorno per i
|
|
||||||
destinatari). Chi la inserisce prima delle 8 viene incluso.
|
|
||||||
- Un nuovo membro che si aggiunge dopo l'avviso della giornata non riceve quello del giorno.
|
|
||||||
- Il compleanno resta anche in Calendario, invariato.
|
|
||||||
|
|
||||||
**Riesame**
|
|
||||||
Se le mail di compleanno risultano troppe, spegnerle e tenere solo push e centro notifiche (come il turno
|
|
||||||
palloni, DD-040). Se in squadra arriva qualcuno nato il 29 febbraio, gestire quel caso (punto 8).
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# DD-048 — Via numero e data tessera dalla dashboard
|
|
||||||
|
|
||||||
**Data:** 6 ottobre 2026
|
|
||||||
**Stato:** Accettata (nessuna migration)
|
|
||||||
|
|
||||||
**Contesto**
|
|
||||||
La scheda di ogni giocatore in `/admin` aveva un blocco «Tesseramento CSI» con numero e data
|
|
||||||
tessera, scritti solo dall'admin (roadmap v1.1). Non serve più tenerli nell'app.
|
|
||||||
|
|
||||||
**Decisione**
|
|
||||||
|
|
||||||
1. Sparisce il blocco «Tesseramento CSI» dalla scheda del giocatore, con il pulsante «Salva
|
|
||||||
tesseramento» e il salvataggio (`useSalvaTesseramento()`).
|
|
||||||
2. Spariscono anche le due viste che ne dipendevano e senza il campo non avrebbero più senso:
|
|
||||||
il badge «Tesserato»/«Da tesserare» sulla scheda e il riquadro «Tesserati» della tab Squadra.
|
|
||||||
3. **Restano** l'export CSV per il tesseramento (non conteneva la tessera) e il resto del profilo
|
|
||||||
amministrativo: documento, certificato, dati personali.
|
|
||||||
4. **Dati:** le colonne `giocatori_squadra.numero_tessera` e `data_tessera` non vengono toccate
|
|
||||||
e continuano a essere lette dal codice, ma nessuna schermata le mostra né le scrive. I valori
|
|
||||||
già inseriti restano a database.
|
|
||||||
|
|
||||||
**Alternative scartate**
|
|
||||||
|
|
||||||
- Eliminare anche le colonne → scartata per ora: serve una migration e una decisione sui valori
|
|
||||||
già inseriti; si può fare dopo senza toccare l'interfaccia.
|
|
||||||
- Lasciare badge e riquadro → scartata: senza il campo resterebbero fermi su «Da tesserare» e
|
|
||||||
`0/N`, un'informazione falsa.
|
|
||||||
|
|
||||||
**Conseguenze**
|
|
||||||
|
|
||||||
- L'admin non può più registrare né correggere la tessera dall'app.
|
|
||||||
- Chi lavora sul codice trova `numeroTessera` e `dataTessera` nel tipo `GiocatoreSquadra` senza
|
|
||||||
uso in interfaccia: sono volutamente lasciati finché le colonne esistono.
|
|
||||||
|
|
||||||
**Riesame**
|
|
||||||
Se tra qualche mese nessuno ha bisogno dei valori già inseriti, eliminare le colonne con una
|
|
||||||
migration e togliere i due campi dal tipo e dalla lettura.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# DD-049 — Caratteri più grandi in tutta l'app
|
|
||||||
|
|
||||||
**Data:** 6 ottobre 2026
|
|
||||||
**Stato:** Accettata (solo interfaccia, nessuna migration)
|
|
||||||
|
|
||||||
**Contesto**
|
|
||||||
L'app si usa soprattutto dal telefono, anche da chi non è pratico. Molti testi erano a 12 px
|
|
||||||
(`text-xs`) e 14 px (`text-sm`): faticosi da leggere, soprattutto in Home, dove le card di
|
|
||||||
avvisi, eventi e statistiche hanno quasi solo testo piccolo.
|
|
||||||
|
|
||||||
**Decisione**
|
|
||||||
|
|
||||||
1. Tutti i testi sotto i 16 px salgono di **un passo**, in ogni pagina:
|
|
||||||
|
|
||||||
| Prima | Dopo |
|
|
||||||
| ----------------- | ----- |
|
|
||||||
| `text-xs` (12 px) | 13 px |
|
|
||||||
| 13 px | 14 px |
|
|
||||||
| 14 px | 15 px |
|
|
||||||
| `text-sm` (14 px) | 15 px |
|
|
||||||
| 15 px | 16 px |
|
|
||||||
|
|
||||||
Si usano le misure fisse `text-[13px]`, `text-[14px]`, `text-[15px]`, `text-[16px]`, già
|
|
||||||
presenti nel codice. `text-base` (16 px) e i titoli grandi non cambiano.
|
|
||||||
|
|
||||||
2. **Restano invariati** i testi delle barre a pillola dei menu, cioè la navigazione in basso
|
|
||||||
(`BottomNav`) e le sottosezioni (`BarraSottosezioni`), e i testi minuscoli da 10 e 11 px
|
|
||||||
(conteggio sulla campanella).
|
|
||||||
3. **Avvisi in Home** (certificati e turno palloni): un solo componente, `Avviso`
|
|
||||||
(`src/components/crapp/Avviso.tsx`), con titolo da 18 px, testi da 15 px, icona propria per
|
|
||||||
ogni caso (il colore non è l'unico segnale), elenchi in righe con il dettaglio a destra. Il
|
|
||||||
turno palloni ha in più la pillola «Oggi» o «In arrivo» e i messaggi in riquadri. Cambia solo
|
|
||||||
l'aspetto, non i testi né le regole su quando un avviso compare.
|
|
||||||
|
|
||||||
**Alternative scartate**
|
|
||||||
|
|
||||||
- Alzare la dimensione base del browser (`rem`) → scartata: ingrandirebbe anche icone, spazi e
|
|
||||||
barre a pillola, e ogni schermata andrebbe ricontrollata.
|
|
||||||
- Ingrandire solo la Home → scartata: le altre pagine avevano gli stessi testi piccoli.
|
|
||||||
- Salire di due passi → scartata: in contenitori stretti (badge, tessere, righe di classifica)
|
|
||||||
il testo andrebbe a capo o verrebbe troncato.
|
|
||||||
|
|
||||||
**Conseguenze**
|
|
||||||
|
|
||||||
- Un testo che stava stretto può andare a capo o troncarsi: non c'è un controllo visivo
|
|
||||||
automatico, va guardato sul telefono. Chi vede un punto rotto segnala la schermata.
|
|
||||||
- Per un testo nuovo, la scala di partenza è 13 px per le note, 15 px per il corpo, 16 px per i
|
|
||||||
titoli di card.
|
|
||||||
- Le card degli eventi sono condivise tra Home e Calendario: crescono insieme.
|
|
||||||
|
|
||||||
**Riesame**
|
|
||||||
Se dopo l'uso sul telefono compaiono testi troncati in più punti, tornare a un passo solo nei
|
|
||||||
contenitori stretti, senza toccare il resto.
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# DD-050 — Avviso a admin e allenatori se una presenza cambia a meno di 6 ore dall'evento
|
|
||||||
|
|
||||||
**Data:** 8 ottobre 2026
|
|
||||||
**Stato:** Implementata per admin e allenatori (migration `m32`, solo admin, applicata in produzione il 9 ottobre 2026; `m33`, anche allenatori)
|
|
||||||
|
|
||||||
**Contesto**
|
|
||||||
Un giocatore può cambiare la risposta a un evento fino all'ultimo minuto. Chi organizza (admin e allenatore)
|
|
||||||
se ne accorge solo riaprendo l'evento: un «assente» detto due ore prima di una partita può far saltare la
|
|
||||||
formazione o il numero minimo per l'allenamento. Oggi nessuna notifica avvisa chi gestisce gli eventi di una
|
|
||||||
risposta (`notifiche.md`, «Cosa non genera nessuna notifica»).
|
|
||||||
|
|
||||||
**Decisione**
|
|
||||||
|
|
||||||
1. **Cosa fa scattare l'avviso.** Una riga di `risposte_presenze` che cambia — nuova, modificata o ritirata —
|
|
||||||
quando mancano **da 0 a 6 ore** all'inizio di una **partita o un allenamento** (non agli altri tipi di
|
|
||||||
evento). Scatta per **qualsiasi
|
|
||||||
cambio di stato**, da qualunque valore a qualunque altro (presente, assente, forse, in ritardo,
|
|
||||||
infortunato) e anche per la prima risposta e per il ritiro; non scatta solo se lo stato resta quello di
|
|
||||||
prima (nessun cambio reale). Vale per qualunque giocatore, anche se la
|
|
||||||
modifica la fa un admin per conto suo.
|
|
||||||
2. **Chi la riceve:** gli **admin e gli allenatori attivi** (chi ha il ruolo `admin` o `allenatore` in
|
|
||||||
`user_roles` e uno slot attivo in `giocatori_squadra`), **tranne la persona a cui appartiene la presenza**
|
|
||||||
(se un admin cambia la propria, non avvisa se stesso). Chi ha il ruolo ma nessuno slot collegato non può
|
|
||||||
ricevere notifiche, perché `notifiche_utente` è per slot.
|
|
||||||
3. **Come funziona.** Un trigger `AFTER INSERT OR UPDATE OF stato OR DELETE` su `risposte_presenze` chiama una
|
|
||||||
funzione `SECURITY DEFINER` (`avvisa_presenza_modificata()`, migration `m32`). La funzione:
|
|
||||||
1. ignora il caso in cui lo stato prima e dopo è lo stesso;
|
|
||||||
2. legge l'evento e continua solo se `tipo` è `partita` o `allenamento` e `inizio_evento(data, ora) - now()`
|
|
||||||
sta tra 0 e 6 ore (stessa `inizio_evento()` di M26: fuso `Europe/Rome`, ora illeggibile = nessun avviso);
|
|
||||||
3. inserisce una riga in `notifiche_utente` per ogni destinatario, con il tipo nuovo `presenza_modificata`.
|
|
||||||
|
|
||||||
Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'**email**, il trigger delle push
|
|
||||||
(esteso con il tipo nuovo) accoda la **push**, la riga è il **centro notifiche in-app** (DD-038). Nessuna
|
|
||||||
route dell'app e nessun codice lato client oltre al nuovo valore di `TipoNotifica`.
|
|
||||||
|
|
||||||
4. **Testo.** Titolo `Presenza modificata: <Nome Cognome>`; corpo su righe: `Evento: <titolo>`,
|
|
||||||
`Data: <GG/MM/AAAA>, ore <ora>`, `Da: <stato prima>`, `A: <stato dopo>`. Gli stati sono «presente»,
|
|
||||||
«assente», «forse», «in ritardo», «infortunato», «nessuna risposta» (prima di una risposta nuova, o dopo
|
|
||||||
un ritiro). Non c'è un link all'evento: il pulsante «Apri CrAPP» della mail e il tocco sulla push aprono l'app.
|
|
||||||
5. **Una notifica per modifica, senza raggruppare.** `evento_id` resta NULL (come i messaggi `admin`), così la
|
|
||||||
`UNIQUE (giocatore_id, evento_id, tipo)` non si applica e due modifiche dello stesso evento non si
|
|
||||||
sovrascrivono. Contro: se la stessa persona cambia idea cinque volte, ognuno dei destinatari riceve cinque
|
|
||||||
notifiche e cinque email.
|
|
||||||
6. **Nessuna configurazione.** Né una soglia modificabile (6 ore è scritto nella funzione), né un interruttore
|
|
||||||
per ruolo: l'email e la push seguono le preferenze già esistenti di ciascuno (`preferenze_utente`, DD-036).
|
|
||||||
|
|
||||||
**Fase di prova (conclusa)**
|
|
||||||
|
|
||||||
La `m32` (9 ottobre 2026) l'ha attivata per i soli admin, per vedere in pratica quante notifiche genera. Dopo la
|
|
||||||
prova la `m33` (stesso giorno) ha aggiunto `'allenatore'` alla lista dei ruoli in `avvisa_presenza_modificata()`:
|
|
||||||
ora vale la regola del punto 2.
|
|
||||||
|
|
||||||
**Alternative scartate**
|
|
||||||
|
|
||||||
- **Una route dell'app invocata dal client dopo il salvataggio** (come `sollecita-presenze`) → il client
|
|
||||||
scrive su `risposte_presenze` direttamente con RLS: una route in più sarebbe aggirabile e dipenderebbe dal
|
|
||||||
fatto che l'app sia ancora aperta al momento del salvataggio. Il trigger copre ogni scrittura.
|
|
||||||
- **Raggruppare in una sola notifica per evento** (upsert con `evento_id`) → perde il dettaglio di chi ha
|
|
||||||
cambiato cosa, e l'upsert non rifa partire la push (il trigger delle push è solo `INSERT`).
|
|
||||||
- **Notificare anche chi ha il ruolo ma è il giocatore stesso** → inutile: sa già cosa ha fatto.
|
|
||||||
|
|
||||||
**Conseguenze**
|
|
||||||
|
|
||||||
- Nuova migration `m32` (CHECK dei tipi, tre funzioni, due trigger); `docs/DATABASE.md` e il catalogo in
|
|
||||||
`notifiche.md` si aggiornano con essa. Applicata in produzione il 9 ottobre 2026.
|
|
||||||
- L'allenatore, che prima non riceveva nulla sulle presenze, riceve questa notifica: aggiunta a
|
|
||||||
[allenatore.md](../modules/allenatore.md#notifiche).
|
|
||||||
- Rumore possibile a ridosso dell'evento (punto 5). Se in pratica dà fastidio, il primo ritocco è raggruppare
|
|
||||||
per evento, non togliere l'avviso.
|
|
||||||
|
|
||||||
**Riesame**
|
|
||||||
Dopo qualche settimana d'uso: se arrivano troppe notifiche per lo stesso giocatore, raggruppare; se 6 ore
|
|
||||||
sono troppe o poche, spostare la soglia (una costante nella funzione).
|
|
||||||
+46
-50
@@ -46,56 +46,52 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
|||||||
|
|
||||||
**Accettate**
|
**Accettate**
|
||||||
|
|
||||||
| ID | Titolo |
|
| ID | Titolo |
|
||||||
| ------------------- | --------------------------------------------------------- |
|
| ------------------- | ------------------------------------------- |
|
||||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||||
| [DD-005](DD-005.md) | Mobile-first |
|
| [DD-005](DD-005.md) | Mobile-first |
|
||||||
| [DD-006](DD-006.md) | AI solo se utile |
|
| [DD-006](DD-006.md) | AI solo se utile |
|
||||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||||
| [DD-022](DD-022.md) | App solo chiara |
|
| [DD-022](DD-022.md) | App solo chiara |
|
||||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||||
| [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-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
||||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
||||||
| [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-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
|
||||||
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
|
||||||
|
|
||||||
**In valutazione**
|
**In valutazione**
|
||||||
|
|
||||||
|
|||||||
@@ -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 💩» |
|
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
||||||
| `/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,8 +132,6 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
|
|||||||
| Sollecito presenze | no | L'allenatore non risponde alle presenze (DD-034). Può invece **far partire** il sollecito . |
|
| 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. |
|
|
||||||
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
|
|
||||||
| 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
|
||||||
|
|||||||
+34
-28
@@ -76,7 +76,7 @@ badge assegnati per voto dai compagni.
|
|||||||
|
|
||||||
## Elenco badge
|
## Elenco badge
|
||||||
|
|
||||||
Riferimento completo per chi lavora sul codice. **In app i 4 badge segreti restano nascosti
|
Riferimento completo per chi lavora sul codice. **In app i 5 badge segreti restano nascosti
|
||||||
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
finché non sbloccati** (fanno parte della sorpresa per i giocatori): elencarli qui, con le
|
||||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||||
informazioni verso l'interfaccia.
|
informazioni verso l'interfaccia.
|
||||||
@@ -93,6 +93,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
|
|||||||
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
| `palloni` | Sherpa dei palloni | quante volte hai confermato il turno palloni (`g.palloni`) — le proposte automatiche non ancora confermate non contano | 3 / 6 / 10 |
|
||||||
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
| `presenze` | Presenza fissa | totale presenze (presente o ritardo) a eventi/partite di sempre, non solo della stagione in corso (`g.presenze`) | 5 / 15 / 30 |
|
||||||
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti (`g.serieAllenamenti`); un infortunio non spezza la serie, un'assenza sì | 3 / 6 / 10 |
|
||||||
|
| `serie-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||||
|
|
||||||
### Badge segreti (booleani, nascosti finché non sbloccati)
|
### Badge segreti (booleani, nascosti finché non sbloccati)
|
||||||
|
|
||||||
@@ -103,12 +104,13 @@ grado effettivo che risulta una volta sbloccato è sempre **`"oro"`** (l'ultimo
|
|||||||
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
`for` sovrascrive), mai `"bronzo"` — l'unica cosa che conta davvero per questi badge è
|
||||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||||
|
|
||||||
| id | nome | condizione esatta |
|
| id | nome | condizione esatta |
|
||||||
| -------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| --------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
| `s-tiebreak` | Uomo tie-break | almeno 2 MVP **e** media pagella ≥ 8, sopra la soglia minima di voti di Pagellone (`g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8`) |
|
||||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
| `s-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||||
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 3`) |
|
||||||
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
| `s-ritardi` | Aspettate, arrivo! | almeno 5 ritardi a eventi (`g.ritardi >= 5`) |
|
||||||
|
| `s-cacche` | Trono di ferro | almeno 3 partite (campionato o amichevole) con 3 o più cacche pre-gara dichiarate (`g.cacche >= 3`) |
|
||||||
|
|
||||||
### Badge social (votati dai compagni, 5 categorie per partita)
|
### Badge social (votati dai compagni, 5 categorie per partita)
|
||||||
|
|
||||||
@@ -147,22 +149,24 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
|||||||
|
|
||||||
### Riepilogo per badge
|
### Riepilogo per badge
|
||||||
|
|
||||||
| # | id | tipo | test unit | test integration |
|
| # | id | tipo | test unit | test integration |
|
||||||
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
|
| --- | ------------------- | ------- | ----------------------------- | --------------------------------------------- |
|
||||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||||
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
| 6 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) |
|
||||||
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||||
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||||
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||||
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||||
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||||
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||||
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||||
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
| 14 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||||
|
| 15 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||||
|
| 16 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -189,9 +193,11 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
|||||||
|
|
||||||
## Limiti noti
|
## Limiti noti
|
||||||
|
|
||||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
|
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||||
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
|
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||||
rimossi il 2026-10-06.
|
serie sono calcolate sui dati reali dalla migration `m9` in avanti, ma "Risposta lampo" e
|
||||||
|
"Mai un forfait" dipendono da `serieConferme`, e `risposto_il` non è ricostruibile per le
|
||||||
|
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
|
||||||
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
|
||||||
"ottenuto" può sparire o apparire retroattivamente.
|
"ottenuto" può sparire o apparire retroattivamente.
|
||||||
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
|
||||||
@@ -207,7 +213,7 @@ UI di rotazione (`TurnoPalloni.tsx`, `PromemoriaPalloni.tsx`), mai per il conteg
|
|||||||
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
Dimostrato con dati veri in `palloni-badge.test.ts`. [palloni.md](palloni.md) aggiornato di
|
||||||
conseguenza.
|
conseguenza.
|
||||||
|
|
||||||
**Risolto (audit completo dei badge (all'epoca 16))**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
**Risolto (audit completo dei 16 badge)**: `s-tiebreak` (`badges.ts:139`) usava `g.mediaVoto`
|
||||||
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
senza applicare `VOTI_MINIMI_PAGELLA`, a differenza del badge normale `pagella` che usa lo
|
||||||
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
stesso campo — un giocatore con un solo voto pagella altissimo e 2 MVP poteva sbloccare il
|
||||||
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
segreto senza che la media fosse statisticamente significativa. Ora `s-tiebreak` richiede anche
|
||||||
@@ -232,7 +238,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
|||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
- Sincronizzare lo stato "visto" su Supabase invece che solo in localStorage.
|
||||||
- Verificare sui dati di stagione che il badge legato alla serie si sblocchino davvero,
|
- Verificare sui dati di stagione che i tre badge legati alle serie si sblocchino davvero,
|
||||||
ora che le serie sono calcolate.
|
ora che le serie sono calcolate.
|
||||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||||
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
- Aggiungere un vincolo (CHECK o FK) sulla colonna `categoria` di `badge_social_voti`.
|
||||||
|
|||||||
@@ -24,11 +24,7 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
|||||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
|
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
|
||||||
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||||
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
|
di evento, i giorni con più eventi si dividono lo spazio.
|
||||||
«Prossimi compleanni» elenca solo quelli del **mese in corso** non ancora passati (oggi
|
|
||||||
compreso), a prescindere dal mese mostrato nella griglia: il 6 ottobre un compleanno del 3
|
|
||||||
non compare più. Le celle della griglia continuano a mostrare tutti i compleanni del mese
|
|
||||||
selezionato.
|
|
||||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||||
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
|
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
|
||||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||||
|
|||||||
@@ -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, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **Fa eccezione il turno palloni** (DD-040, dalla
|
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita. **Fa eccezione il turno palloni** (DD-040, dalla
|
||||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
`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
-46
@@ -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, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
- **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,21 +34,19 @@ in-app** (storico in `notifiche_utente`, per tutti i giocatori) ed **email**
|
|||||||
([notifiche-email.md](notifiche-email.md): ogni riga di `notifiche_utente` genera una mail, **tranne
|
([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ì |
|
|
||||||
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
|
|
||||||
|
|
||||||
Le notifiche generate dal server (righe 1-6, 8 e 9) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
Le notifiche generate dal server (righe 1-6) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||||
automatiche (4, 5, 8 e 9; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
automatiche (4 e 5; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
||||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
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»).
|
||||||
|
|
||||||
@@ -65,18 +63,15 @@ volta sola. Nell'email il titolo diventa l'oggetto e il corpo segue il formato d
|
|||||||
[notifiche-email.md](notifiche-email.md#contenuto-delle-mail) (link «Apri CrAPP» e avviso di
|
[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 |
|
|
||||||
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
|
|
||||||
|
|
||||||
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`,
|
||||||
@@ -104,11 +99,10 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
|
|||||||
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
||||||
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
||||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi, **salvo** quando la risposta cambia a meno di 6 ore da una partita o un allenamento (riga 9, DD-050).
|
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
||||||
- 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
|
||||||
|
|
||||||
@@ -149,29 +143,21 @@ 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.
|
||||||
|
|
||||||
### Test che verificano il catalogo
|
### Test che verificano il catalogo
|
||||||
|
|
||||||
| Cosa | Test |
|
| Cosa | Test |
|
||||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.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` |
|
| 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` |
|
||||||
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
|
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||||
| 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`) |
|
|
||||||
|
|
||||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,27 +37,22 @@ corrente dinamico (vedi sotto).
|
|||||||
|
|
||||||
## Obiettivi definiti
|
## Obiettivi definiti
|
||||||
|
|
||||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||||
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
|
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||||
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
|
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||||
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
|
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
| `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 la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
|
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||||
|
|
||||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||||
primo obiettivo non completato).
|
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||||
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
|
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||||
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.
|
||||||
@@ -71,22 +65,7 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d
|
|||||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
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. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
|
seguono di conseguenza.
|
||||||
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 è
|
||||||
@@ -165,29 +144,21 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
|
|||||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
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 anche con i convocati) |
|
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
| 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 un allenamento e una partita con convocati e presenze reali,
|
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||||
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
|
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||||
- **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
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ 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.
|
||||||
|
|
||||||
@@ -335,6 +336,7 @@ 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,9 +362,12 @@ Campi esportati.
|
|||||||
|
|
||||||
## Tracciamento tesseramento
|
## Tracciamento tesseramento
|
||||||
|
|
||||||
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
|
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||||
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
|
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||||
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
|
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||||
|
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||||
|
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||||
|
complessivo della squadra.
|
||||||
|
|
||||||
## Completamento profilo
|
## Completamento profilo
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Modulo — Serie di presenze
|
# Modulo — Serie di presenze
|
||||||
|
|
||||||
**Stato:** implementato — le due serie (allenamenti e partite) calcolate sui dati reali
|
**Stato:** implementato — tutte e tre le serie calcolate sui dati reali
|
||||||
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
**File principali:** `src/lib/serie.ts`, `src/lib/presenze.ts`, `src/lib/rosa.ts`,
|
||||||
`src/components/crapp/SerieCard.tsx`
|
`src/components/crapp/SerieCard.tsx`
|
||||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||||
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
`test/integration/scritture.test.ts` (il trigger che congela `risposto_il`),
|
||||||
`test/integration/serie-allenamenti-badge.test.ts`
|
`test/integration/serie-allenamenti-badge.test.ts`, `test/integration/serie-conferme-badge.test.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -19,18 +19,15 @@ requisiti di sblocco di alcuni [badge](badge.md) e di un [obiettivo di squadra](
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Le due serie in sintesi
|
## Le tre serie in sintesi
|
||||||
|
|
||||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||||
| ------------- | ------------------ | -------------------------------------------- | ------------ |
|
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||||
|
| `conferme` | `serieConferme` | Eventi consecutivi con risposta entro 24h dalla convocazione | 3, 8, 15, 20 |
|
||||||
|
|
||||||
La serie «Conferme 24h», con i badge «Risposta lampo» e «Mai un forfait» che ne dipendevano, è stata rimossa il
|
Esiste un quarto contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
||||||
2026-10-06: restano `Giocatore.streak` e le due serie sopra. La colonna `risposto_il` e il trigger che la congela
|
|
||||||
restano nel database, ma oggi nessuna statistica li usa.
|
|
||||||
|
|
||||||
Esiste un terzo contatore fuori da questo modulo, `Giocatore.streak`: la stessa regola delle
|
|
||||||
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
presenze ma **su partite e allenamenti insieme**. Non ha card né traguardi, compare come
|
||||||
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
"presenze consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||||
`src/routes/profilo.tsx`.
|
`src/routes/profilo.tsx`.
|
||||||
@@ -70,10 +67,10 @@ il cronometro»), che riscrive la risposta provando a riscrivere anche `risposto
|
|||||||
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
che il database abbia tenuto la prima: se qualcuno togliesse il trigger, quel test diventa
|
||||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||||
|
|
||||||
| Colonna | Cosa registra | Chi la usa |
|
| Colonna | Cosa registra | Chi la usa |
|
||||||
| --------------- | --------------------- | -------------------------------------- |
|
| --------------- | --------------------- | ----------------------- |
|
||||||
| `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
|
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||||
|
|
||||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||||
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
risponde di nuovo, riparte il cronometro. È voluto — ha ritirato la risposta.
|
||||||
@@ -150,6 +147,26 @@ Conseguenze da conoscere prima di cambiare qualcosa:
|
|||||||
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
significa che eventi storici importati senza presenze schiacciano a zero le serie di tutti.
|
||||||
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
- Senza `tipo` conta partite e allenamenti insieme: è così che si ottiene `streak`.
|
||||||
|
|
||||||
|
### `serieConferme()` — conferme entro 24 ore
|
||||||
|
|
||||||
|
```ts
|
||||||
|
serieConferme(giocatoreId, eventi, tempi, oggi?)
|
||||||
|
```
|
||||||
|
|
||||||
|
Onorato = esiste una risposta **e** `risposto_il − creato_il ≤ 24h` (confronto inclusivo,
|
||||||
|
costante `ORE_24`, entrambi gli istanti passati da `Date.parse`).
|
||||||
|
|
||||||
|
- Conta **partite e allenamenti insieme**, non c'è una versione per tipo.
|
||||||
|
- **Lo stato non conta**: anche un "assente" dato in fretta tiene viva la serie. È una serie
|
||||||
|
sulla reattività, non sulla presenza.
|
||||||
|
- **Gli eventi senza `creatoIl` vengono saltati e non spezzano la serie.** Sono gli eventi
|
||||||
|
costruiti dal client e mai salvati a database — i compleanni di `compleanniEventi()` e la
|
||||||
|
bozza di `eventoVuoto()`. Senza istante di convocazione la domanda "ha risposto in fretta?"
|
||||||
|
non ha risposta, e trattarli come un buco punirebbe il giocatore per un dettaglio tecnico.
|
||||||
|
- **Le 24 ore partono dalla creazione dell'evento**, non da un invio di notifica: oggi un
|
||||||
|
momento di "convocazione mandata" distinto non esiste. Se un domani ci sarà, è quello
|
||||||
|
l'istante giusto da confrontare.
|
||||||
|
|
||||||
### Lettura e cache
|
### Lettura e cache
|
||||||
|
|
||||||
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
`fetchPresenze()` fa **una sola query** e costruisce due mappe:
|
||||||
@@ -246,6 +263,8 @@ Toccare la regola di calcolo muove anche questi, che non hanno logica propria:
|
|||||||
| Dove | Cosa | Soglie |
|
| Dove | Cosa | Soglie |
|
||||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||||
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
| `badges.ts` `serie-allenamenti` | "Sempre in palestra", su `serieAllenamenti` | bronzo 3, argento 6, oro 10 |
|
||||||
|
| `badges.ts` `serie-conferme` | "Risposta lampo", su `serieConferme` | bronzo 3, argento 8, oro 15 |
|
||||||
|
| `badges.ts` `s-mai-forfait` | Badge segreto: `serieConferme >= 10` **e** `presenze >= 15` | — |
|
||||||
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
| `obiettivi.ts` `o11` | "Continuità di squadra": giocatori con `serieAllenamenti >= 3` | target 12 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -301,8 +320,8 @@ risultato finale, ma va sistemato se un giorno serve l'ordine esatto.
|
|||||||
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
**`oggi` è sempre in fuso Italia.** `dataOggi()` (`src/lib/scout-live.ts`) usa
|
||||||
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
`Intl.DateTimeFormat` con `timeZone: "Europe/Rome"`, non i getter locali di `Date` né
|
||||||
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
`toISOString()`: il cambio ora legale/solare lo gestisce il database IANA dei fusi, non un
|
||||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
|
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||||
conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
|
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||||
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
`toISOString()` (sempre UTC) mentre il conteggio presenze usava i getter locali di `Date`
|
||||||
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
(corretti solo se il processo gira già in fuso italiano): nelle prime ore della giornata
|
||||||
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
italiana potevano non essere d'accordo su cosa fosse "oggi".
|
||||||
|
|||||||
@@ -48,19 +48,20 @@ gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
|||||||
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
|
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»). La
|
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). 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
+2046
-249
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.4.1",
|
"version": "1.2.3",
|
||||||
"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.104.1",
|
"@tanstack/react-query": "^5.101.1",
|
||||||
"@tanstack/react-router": "^1.170.41",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
"@tanstack/react-start": "^1.168.60",
|
"@tanstack/react-start": "^1.168.32",
|
||||||
"@tanstack/router-plugin": "^1.168.42",
|
"@tanstack/router-plugin": "^1.168.23",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gravità di un avviso in Home. Il colore non è l'unico segnale: ogni avviso ha anche un'icona
|
|
||||||
* sua e un titolo, così si distingue anche senza vedere i colori.
|
|
||||||
*/
|
|
||||||
export type TonoAvviso = "critico" | "attenzione" | "scaduto" | "azione";
|
|
||||||
|
|
||||||
const toni: Record<TonoAvviso, string> = {
|
|
||||||
critico: "bg-destructive text-destructive-foreground",
|
|
||||||
attenzione: "bg-warning text-warning-foreground",
|
|
||||||
scaduto: "bg-primary text-primary-foreground",
|
|
||||||
azione: "bg-accent-grad text-accent-foreground",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Una riga di un elenco: a sinistra chi, a destra il dettaglio (es. la scadenza). */
|
|
||||||
export type RigaAvviso = { chi: string; cosa: string };
|
|
||||||
|
|
||||||
type ProprietaCorpo = {
|
|
||||||
tono: TonoAvviso;
|
|
||||||
icona: LucideIcon;
|
|
||||||
titolo: string;
|
|
||||||
/** Frasi di spiegazione, una per paragrafo. */
|
|
||||||
testi?: string[];
|
|
||||||
/** Elenco di persone con il loro dettaglio; con più di una riga compare il conteggio. */
|
|
||||||
elenco?: RigaAvviso[];
|
|
||||||
/** Mostra la freccia: tutta la card porta a un'altra schermata. */
|
|
||||||
cliccabile?: boolean;
|
|
||||||
/** Parola breve accanto al titolo (es. «Oggi»), per dire subito quando riguarda. */
|
|
||||||
etichetta?: string;
|
|
||||||
/** I testi in riquadri propri, uno per messaggio: per chi deve fare una cosa precisa. */
|
|
||||||
riquadri?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Contenuto dell'avviso, senza margini né link: è la parte che si può verificare nei test. */
|
|
||||||
export function CorpoAvviso({
|
|
||||||
tono,
|
|
||||||
icona: Icona,
|
|
||||||
titolo,
|
|
||||||
testi = [],
|
|
||||||
elenco = [],
|
|
||||||
cliccabile = false,
|
|
||||||
etichetta,
|
|
||||||
riquadri = false,
|
|
||||||
}: ProprietaCorpo) {
|
|
||||||
return (
|
|
||||||
<div className={cn("rounded-3xl p-5 shadow-pop", toni[tono])}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-current/15"
|
|
||||||
>
|
|
||||||
<Icona className="h-6 w-6" />
|
|
||||||
</span>
|
|
||||||
<p className="min-w-0 flex-1 font-display text-lg uppercase leading-tight tracking-wide">
|
|
||||||
{titolo}
|
|
||||||
</p>
|
|
||||||
{etichetta ? (
|
|
||||||
<span className="rounded-full bg-black/25 px-3 py-1 text-[13px] font-bold uppercase tracking-wide">
|
|
||||||
{etichetta}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{elenco.length > 1 ? (
|
|
||||||
<span
|
|
||||||
aria-label={`${elenco.length} persone`}
|
|
||||||
className="rounded-full bg-current/15 px-3 py-1 text-[13px] font-bold tabular-nums"
|
|
||||||
>
|
|
||||||
{elenco.length}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{cliccabile ? <ChevronRight aria-hidden className="h-6 w-6 shrink-0" /> : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{testi.map((t) => (
|
|
||||||
<p
|
|
||||||
key={t}
|
|
||||||
className={cn(
|
|
||||||
"mt-3 text-[15px] leading-snug",
|
|
||||||
riquadri && "rounded-2xl bg-black/15 p-3.5 font-semibold",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{elenco.length > 0 ? (
|
|
||||||
<ul className="mt-3 divide-y divide-current/15 border-t border-current/15">
|
|
||||||
{elenco.map((r) => (
|
|
||||||
<li
|
|
||||||
key={`${r.chi}-${r.cosa}`}
|
|
||||||
className="flex items-baseline justify-between gap-3 py-2.5 text-[15px] leading-snug"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 truncate font-semibold">{r.chi}</span>
|
|
||||||
<span className="shrink-0 text-right tabular-nums">{r.cosa}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Avviso in Home: comparsa graduale, margini della colonna e, se serve, link ai documenti del
|
|
||||||
* profilo. Tutti gli avvisi (certificati, turno palloni) passano da qui per avere lo stesso aspetto.
|
|
||||||
*/
|
|
||||||
export function Avviso({ indice = 0, ...corpo }: ProprietaCorpo & { indice?: number }) {
|
|
||||||
const card = <CorpoAvviso {...corpo} />;
|
|
||||||
return (
|
|
||||||
<Reveal indice={indice} className="mx-5 mt-4">
|
|
||||||
{corpo.cliccabile ? (
|
|
||||||
<Link
|
|
||||||
to="/profilo"
|
|
||||||
search={{ tab: "documenti" }}
|
|
||||||
className="premi block rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
||||||
>
|
|
||||||
{card}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div role="status">{card}</div>
|
|
||||||
)}
|
|
||||||
</Reveal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CalendarClock, FileClock, FileX } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
import { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { useProfili } from "@/lib/profili";
|
import { useProfili } from "@/lib/profili";
|
||||||
import {
|
import {
|
||||||
avvisiCertificatiUtente,
|
avvisiCertificatiUtente,
|
||||||
@@ -12,6 +12,45 @@ 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" | "destructive";
|
||||||
|
titolo: string;
|
||||||
|
righe: string[];
|
||||||
|
cliccabile?: boolean;
|
||||||
|
}) {
|
||||||
|
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||||
|
colore === "warning"
|
||||||
|
? "bg-warning text-warning-foreground"
|
||||||
|
: colore === "destructive"
|
||||||
|
? "bg-destructive text-destructive-foreground"
|
||||||
|
: "bg-primary text-primary-foreground"
|
||||||
|
} ${cliccabile ? "premi" : ""}`;
|
||||||
|
const contenuto = (
|
||||||
|
<>
|
||||||
|
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||||
|
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||||
|
</p>
|
||||||
|
{righe.map((r) => (
|
||||||
|
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||||
|
{r}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return cliccabile ? (
|
||||||
|
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||||
|
{contenuto}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={classi}>{contenuto}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
|
* 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.
|
||||||
@@ -33,31 +72,28 @@ export function AvvisoCertificati() {
|
|||||||
base,
|
base,
|
||||||
});
|
});
|
||||||
const cardPersonale = personaleMancante ? (
|
const cardPersonale = personaleMancante ? (
|
||||||
<Avviso
|
<Card
|
||||||
tono="critico"
|
colore="destructive"
|
||||||
icona={FileX}
|
|
||||||
titolo="Certificato mancante"
|
titolo="Certificato mancante"
|
||||||
cliccabile
|
cliccabile
|
||||||
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
||||||
/>
|
/>
|
||||||
) : personale ? (
|
) : personale ? (
|
||||||
personale.giorni < 0 ? (
|
personale.giorni < 0 ? (
|
||||||
<Avviso
|
<Card
|
||||||
tono="scaduto"
|
colore="primary"
|
||||||
icona={FileClock}
|
|
||||||
titolo="Certificato scaduto"
|
titolo="Certificato scaduto"
|
||||||
cliccabile
|
cliccabile
|
||||||
testi={[
|
righe={[
|
||||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Avviso
|
<Card
|
||||||
tono="attenzione"
|
colore="warning"
|
||||||
icona={CalendarClock}
|
|
||||||
titolo="Certificato in scadenza"
|
titolo="Certificato in scadenza"
|
||||||
cliccabile
|
cliccabile
|
||||||
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null;
|
) : null;
|
||||||
@@ -68,33 +104,30 @@ export function AvvisoCertificati() {
|
|||||||
<>
|
<>
|
||||||
{cardPersonale}
|
{cardPersonale}
|
||||||
{mancanti.length > 0 ? (
|
{mancanti.length > 0 ? (
|
||||||
<Avviso
|
<Card
|
||||||
tono="critico"
|
colore="destructive"
|
||||||
icona={FileX}
|
|
||||||
titolo="Certificati mancanti"
|
titolo="Certificati mancanti"
|
||||||
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
|
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{scaduti.length > 0 ? (
|
{scaduti.length > 0 ? (
|
||||||
<Avviso
|
<Card
|
||||||
tono="scaduto"
|
colore="primary"
|
||||||
icona={FileClock}
|
|
||||||
titolo="Certificati scaduti"
|
titolo="Certificati scaduti"
|
||||||
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{inScadenza.length > 0 ? (
|
{inScadenza.length > 0 ? (
|
||||||
<Avviso
|
<Card
|
||||||
tono="attenzione"
|
colore="warning"
|
||||||
icona={CalendarClock}
|
|
||||||
titolo="Certificati in scadenza"
|
titolo="Certificati in scadenza"
|
||||||
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
|
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||||
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
|
return `${a.nome} ${a.cognome} — ${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-[15px] leading-relaxed">
|
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
||||||
{def.descrizione}
|
{def.descrizione}
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,15 +42,13 @@ 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Stato attuale
|
Stato attuale
|
||||||
</p>
|
</p>
|
||||||
{grado ? (
|
{grado ? (
|
||||||
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
|
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
|
||||||
{meta?.label}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
<span className="text-xs font-bold uppercase text-muted-foreground">
|
||||||
In progresso
|
In progresso
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -61,19 +59,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-[13px] text-muted-foreground">
|
<p className="mt-2 text-xs 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-[13px] text-success">Hai raggiunto il livello massimo.</p>
|
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Soglie
|
Soglie
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -90,7 +88,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[13px] font-bold uppercase",
|
"text-xs font-bold uppercase",
|
||||||
raggiunto ? gm.text : "text-muted-foreground",
|
raggiunto ? gm.text : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -104,7 +102,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
|||||||
>
|
>
|
||||||
{def.soglie[g]}
|
{def.soglie[g]}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
|
<p className="text-xs text-muted-foreground">{def.unita}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -112,7 +110,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-[15px] font-semibold text-accent">
|
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
|
||||||
“{def.celebrazione}”
|
“{def.celebrazione}”
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -144,7 +142,7 @@ export function BadgeDrawer({
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
Indietro
|
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-[13px] font-bold uppercase tracking-wide text-accent">
|
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||||
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
|
{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-[15px] text-muted-foreground">{notifica.testo}</p>
|
<p className="mt-1.5 text-sm 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-[15px] font-bold uppercase tracking-wide text-accent-foreground"
|
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
|
||||||
>
|
>
|
||||||
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
|
{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-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
|
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{b.valore} {b.def.unita}
|
{b.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-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
|
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||||
)}
|
)}
|
||||||
</Reveal>
|
</Reveal>
|
||||||
);
|
);
|
||||||
@@ -90,15 +90,13 @@ 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-[15px] leading-relaxed">
|
<DrawerDescription className="mt-1 text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
|
||||||
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">
|
||||||
@@ -111,7 +109,7 @@ function SocialDrawer({
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
Indietro
|
Indietro
|
||||||
</button>
|
</button>
|
||||||
@@ -141,7 +139,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-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||||
Collezione badge
|
Collezione badge
|
||||||
</p>
|
</p>
|
||||||
<p className="font-display text-4xl leading-none">
|
<p className="font-display text-4xl leading-none">
|
||||||
@@ -149,7 +147,7 @@ export function CollezioneBadge({
|
|||||||
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] text-primary-foreground/70">
|
<p className="text-xs text-primary-foreground/70">
|
||||||
<Numero valore={pct} suffisso="%" /> completata
|
<Numero valore={pct} suffisso="%" /> completata
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,12 +156,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-[13px] font-bold uppercase tracking-wide text-accent">
|
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||||
Prossimo traguardo
|
Prossimo traguardo
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
|
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
|
||||||
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +169,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{c.sbloccati.length > 0 ? (
|
{c.sbloccati.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Sbloccati ({c.sbloccati.length})
|
Sbloccati ({c.sbloccati.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -186,7 +184,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{c.inProgresso.length > 0 ? (
|
{c.inProgresso.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
In progresso ({c.inProgresso.length})
|
In progresso ({c.inProgresso.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -201,7 +199,7 @@ export function CollezioneBadge({
|
|||||||
|
|
||||||
{socialVinti.length > 0 ? (
|
{socialVinti.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Votati dai compagni
|
Votati dai compagni
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -209,8 +207,8 @@ export function CollezioneBadge({
|
|||||||
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
|
<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-[15px] font-bold leading-tight">{cat.nome}</p>
|
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs 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>
|
||||||
@@ -221,7 +219,7 @@ export function CollezioneBadge({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Badge segreti
|
Badge segreti
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -231,8 +229,8 @@ export function CollezioneBadge({
|
|||||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
<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-[15px] font-bold leading-tight">{b.def.nome}</p>
|
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,10 +243,8 @@ 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-[15px] font-bold leading-tight text-muted-foreground">
|
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
|
||||||
???
|
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||||
</p>
|
|
||||||
<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-[13px] text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{voci ? <span>{voci}</span> : null}
|
{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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs 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-[15px]">
|
<div key={i} className="flex items-center gap-2 text-sm">
|
||||||
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
|
<span className="w-6 shrink-0 text-center font-display text-xs 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-[13px] text-muted-foreground">{g.ruolo}</span> : null}
|
{g.ruolo ? <span className="text-xs 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-[13px] text-muted-foreground">
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
<span className="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-[13px] text-muted-foreground">
|
<div className="mt-3 space-y-0.5 text-xs 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-[13px]">
|
<div className="text-xs">
|
||||||
<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-[13px] font-bold">
|
<div className="flex items-center justify-between text-xs font-bold">
|
||||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
<span className="truncate text-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-[13px] text-muted-foreground">
|
<p className="mb-2 text-xs 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-[13px] font-bold text-success-foreground"
|
className="flex items-center justify-center bg-success text-xs 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-[13px] font-bold text-destructive-foreground"
|
className="flex items-center justify-center bg-destructive text-xs 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-[13px] text-muted-foreground">
|
<p className="text-xs 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,22 +248,20 @@ export function DettaglioCsiEsteso({
|
|||||||
const { data, isLoading } = useCsiPartita(matchId);
|
const { data, isLoading } = useCsiPartita(matchId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||||
<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-[13px] text-muted-foreground">
|
<p className="text-xs 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-[15px] font-bold">
|
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
<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">
|
||||||
@@ -274,7 +272,7 @@ export function DettaglioCsiEsteso({
|
|||||||
) : null}
|
) : null}
|
||||||
{data.precedenti ? (
|
{data.precedenti ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||||
</div>
|
</div>
|
||||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card } from "@/components/crapp/ui-bits";
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||||
import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
|
import type { Evento } from "@/lib/eventi";
|
||||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||||
@@ -75,8 +75,7 @@ export function EventoCard({
|
|||||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||||
const io = useGiocatoreInCampo();
|
const io = useGiocatoreInCampo();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
|
const rosa = squadra.filter(inRosa);
|
||||||
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
|
|
||||||
const stato = io ? risposte[io.id] : undefined;
|
const stato = io ? risposte[io.id] : undefined;
|
||||||
const presentiVeri = rosa.filter(
|
const presentiVeri = rosa.filter(
|
||||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||||
@@ -102,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-[15px] font-bold leading-tight">{evento.titolo}</h3>
|
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
|
||||||
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
|
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{etichettaData(evento.data)}
|
{etichettaData(evento.data)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -140,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-[13px] font-bold uppercase tracking-wide",
|
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||||
tipo.className,
|
tipo.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tipo.label}
|
{tipo.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{etichettaData(evento.data)}
|
{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>
|
||||||
@@ -154,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-[13px] text-muted-foreground">{sottotitoloPartita}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
|
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<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>
|
||||||
@@ -175,12 +174,12 @@ export function EventoCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{noteCard ? (
|
{noteCard ? (
|
||||||
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
|
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{metaStato ? (
|
{metaStato ? (
|
||||||
<p
|
<p
|
||||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
|
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<span aria-hidden>{metaStato.emoji}</span>
|
<span aria-hidden>{metaStato.emoji}</span>
|
||||||
@@ -188,7 +187,7 @@ export function EventoCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{io && eConvocato(evento, io.id) ? (
|
{io ? (
|
||||||
<div
|
<div
|
||||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { formatData } from "@/lib/crapp-data";
|
|
||||||
import { microcopyObiettivo, progressoObiettivo, type ObiettivoSquadra } from "@/lib/obiettivi";
|
|
||||||
import { Barra } from "@/components/motion/Barra";
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger,
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
|
|
||||||
function Elenco({ titolo, voci, segno }: { titolo: string; voci: string[]; segno: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
|
||||||
{titolo}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-1.5">
|
|
||||||
{voci.map((v) => (
|
|
||||||
<li key={v} className="flex gap-2 text-[15px] leading-snug">
|
|
||||||
<span aria-hidden className="shrink-0 font-bold text-accent">
|
|
||||||
{segno}
|
|
||||||
</span>
|
|
||||||
<span>{v}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Blocco({ titolo, testo }: { titolo: string; testo: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="mb-1 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
|
||||||
{titolo}
|
|
||||||
</p>
|
|
||||||
<p className="text-[15px] leading-relaxed">{testo}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Corpo della card di dettaglio; esportato per i test, va usato dentro un Drawer. */
|
|
||||||
export function SchedaObiettivo({ o }: { o: ObiettivoSquadra }) {
|
|
||||||
const pct = progressoObiettivo(o);
|
|
||||||
const fatto = pct >= 100;
|
|
||||||
const d = o.dettaglio;
|
|
||||||
return (
|
|
||||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4 pt-0">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<span className="grid h-14 w-14 shrink-0 place-items-center rounded-2xl bg-secondary text-2xl ring-1 ring-border">
|
|
||||||
{o.emoji}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<DrawerTitle className="text-xl font-bold leading-tight">{o.titolo}</DrawerTitle>
|
|
||||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
|
||||||
{o.descrizione}
|
|
||||||
</DrawerDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Stato attuale
|
|
||||||
</p>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[13px] font-bold uppercase",
|
|
||||||
fatto ? "text-success" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fatto ? "Completato" : "In corso"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 font-display text-3xl leading-none">
|
|
||||||
{o.valore} <span className="text-lg text-muted-foreground">su {o.target}</span>{" "}
|
|
||||||
<span className="text-lg text-muted-foreground">{o.unita}</span>
|
|
||||||
</p>
|
|
||||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
|
||||||
{pct}%{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-[13px] font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Blocco titolo="Come si calcola" testo={d.comeSiCalcola} />
|
|
||||||
<Elenco titolo="Cosa conta" voci={d.conta} segno="+" />
|
|
||||||
{d.perse ? <Elenco titolo="Presenza persa" voci={d.perse} segno="−" /> : null}
|
|
||||||
<Elenco
|
|
||||||
titolo={d.perse ? "Non entra nel calcolo" : "Cosa non conta"}
|
|
||||||
voci={d.nonConta}
|
|
||||||
segno={d.perse ? "·" : "−"}
|
|
||||||
/>
|
|
||||||
<Blocco titolo="Quando vale" testo={d.periodo} />
|
|
||||||
<Blocco titolo="Da dove arrivano i dati" testo={d.fonte} />
|
|
||||||
<Blocco titolo="Un esempio" testo={d.esempio} />
|
|
||||||
|
|
||||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
|
||||||
{o.impatto}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drawer cliccabile che spiega nel dettaglio un obiettivo di squadra. */
|
|
||||||
export function ObiettivoDrawer({
|
|
||||||
obiettivo,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
obiettivo: ObiettivoSquadra;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
return (
|
|
||||||
<Drawer open={open} onOpenChange={setOpen}>
|
|
||||||
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
|
||||||
<DrawerContent>
|
|
||||||
<DrawerHeader className="sr-only">
|
|
||||||
<DrawerTitle>{obiettivo.titolo}</DrawerTitle>
|
|
||||||
<DrawerDescription>{obiettivo.descrizione}</DrawerDescription>
|
|
||||||
</DrawerHeader>
|
|
||||||
<SchedaObiettivo o={obiettivo} />
|
|
||||||
<DrawerFooter>
|
|
||||||
<DrawerClose asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
|
||||||
>
|
|
||||||
Indietro
|
|
||||||
</button>
|
|
||||||
</DrawerClose>
|
|
||||||
</DrawerFooter>
|
|
||||||
</DrawerContent>
|
|
||||||
</Drawer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
||||||
</p>
|
</p>
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||||
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
|
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-[13px] text-muted-foreground">
|
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
|
||||||
Carico le pagelle…
|
Carico le pagelle…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -70,17 +70,15 @@ 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-[13px]" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||||
{g.nome}
|
<span className="text-xs text-muted-foreground">
|
||||||
</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-[13px] font-semibold uppercase text-muted-foreground">
|
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
Sei tu
|
Sei tu
|
||||||
</span>
|
</span>
|
||||||
) : chiuse ? (
|
) : chiuse ? (
|
||||||
@@ -91,7 +89,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-[13px] font-bold uppercase transition-colors disabled:opacity-50",
|
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
|
||||||
mio !== undefined
|
mio !== undefined
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-card text-foreground",
|
: "bg-card text-foreground",
|
||||||
@@ -109,7 +107,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-[13px] font-bold tabular-nums transition-transform active:scale-90",
|
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
|
||||||
mio === v
|
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-[15px] uppercase tracking-wide">{titolo}</h3>
|
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
||||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
{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-[15px]">
|
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||||
<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-[13px] font-bold text-primary-foreground disabled:opacity-60"
|
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
{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-[13px] font-bold tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-xs 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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{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-[13px] font-bold tabular-nums text-muted-foreground">
|
<span className="shrink-0 text-xs 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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
{sporco ? "Salva" : "Salvato"}
|
{sporco ? "Salva" : "Salvato"}
|
||||||
@@ -486,9 +486,7 @@ export function ProfiloAmministrativo({
|
|||||||
<SezioneTendina
|
<SezioneTendina
|
||||||
titolo="Dati per il tesseramento"
|
titolo="Dati per il tesseramento"
|
||||||
indice={indice}
|
indice={indice}
|
||||||
azione={
|
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
||||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{corpo}
|
{corpo}
|
||||||
</SezioneTendina>
|
</SezioneTendina>
|
||||||
@@ -523,10 +521,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-[16px] uppercase tracking-wide">
|
<span className="font-display text-[15px] uppercase tracking-wide">
|
||||||
Completa il tuo profilo
|
Completa il tuo profilo
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||||
</div>
|
</div>
|
||||||
<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
|
||||||
@@ -534,7 +532,7 @@ export function CompletaProfilo({
|
|||||||
style={{ width: `${perc}%` }}
|
style={{ width: `${perc}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[14px] text-muted-foreground">
|
<p className="mt-2 text-[13px] 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,5 +1,4 @@
|
|||||||
import { Volleyball } from "lucide-react";
|
import { AlertCircle } 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";
|
||||||
@@ -18,8 +17,6 @@ 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;
|
||||||
@@ -39,7 +36,6 @@ 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(
|
||||||
@@ -48,13 +44,15 @@ export function PromemoriaPalloni() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avviso
|
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
|
||||||
tono="azione"
|
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||||
icona={Volleyball}
|
<AlertCircle className="h-5 w-5" /> Turno palloni
|
||||||
titolo="Turno palloni"
|
</p>
|
||||||
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
|
{messaggi.map((m) => (
|
||||||
testi={messaggi}
|
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
|
||||||
riquadri
|
{m}
|
||||||
/>
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
|||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import { Barra } from "@/components/motion/Barra";
|
import { Barra } from "@/components/motion/Barra";
|
||||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||||
import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
|
import type { EventoTipo } from "@/lib/eventi";
|
||||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||||
@@ -20,12 +20,9 @@ export function RosaPresenze({
|
|||||||
eventoId,
|
eventoId,
|
||||||
data,
|
data,
|
||||||
tipo = "allenamento",
|
tipo = "allenamento",
|
||||||
convocati = [],
|
|
||||||
}: {
|
}: {
|
||||||
eventoId: string;
|
eventoId: string;
|
||||||
data: string;
|
data: string;
|
||||||
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
|
|
||||||
convocati?: string[];
|
|
||||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||||
tipo?: EventoTipo;
|
tipo?: EventoTipo;
|
||||||
}) {
|
}) {
|
||||||
@@ -37,8 +34,7 @@ export function RosaPresenze({
|
|||||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||||
const mittente = useGiocatoreBase();
|
const mittente = useGiocatoreBase();
|
||||||
const puoSollecitare = usePuoGestireEventi();
|
const puoSollecitare = usePuoGestireEventi();
|
||||||
const anagrafica = useAnagraficaRosa();
|
const rosa = useAnagraficaRosa();
|
||||||
const rosa = rosaConvocata(anagrafica, convocati);
|
|
||||||
const [sollecito, setSollecito] = useState(false);
|
const [sollecito, setSollecito] = useState(false);
|
||||||
const passato = data < dataOggi();
|
const passato = data < dataOggi();
|
||||||
|
|
||||||
@@ -74,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-[15px] font-bold">
|
<p className="text-sm 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>
|
||||||
@@ -88,7 +84,7 @@ export function RosaPresenze({
|
|||||||
<span
|
<span
|
||||||
key={s}
|
key={s}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-2.5 py-1 text-[13px] font-bold",
|
"rounded-full px-2.5 py-1 text-xs font-bold",
|
||||||
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -96,14 +92,14 @@ export function RosaPresenze({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold text-muted-foreground">
|
||||||
❔ {mancanti.length} da rispondere
|
❔ {mancanti.length} da rispondere
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{io && rosa.some((g) => g.id === io.id) ? (
|
{io ? (
|
||||||
<div className="mt-4 border-t border-border pt-3">
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
La tua risposta
|
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">
|
||||||
@@ -118,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-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
|
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||||
attivo
|
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",
|
||||||
@@ -137,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-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{sollecito ? (
|
{sollecito ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -150,7 +146,7 @@ export function RosaPresenze({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
|
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
|
||||||
Carico le risposte…
|
Carico le risposte…
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -198,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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{titolo} · {n}
|
{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-[13px]" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{g.nome}</span>
|
||||||
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
|
<span className="shrink-0 text-xs 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-[13px] opacity-80">{sottotitolo}</span>
|
<span className="block text-xs 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-[15px] font-bold leading-tight">{s.def.label}</p>
|
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
|
<p className="text-xs 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-[13px] text-muted-foreground">
|
<p className="mt-2 text-xs 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-[15px] font-bold leading-tight">
|
<p className="text-sm font-bold leading-tight">
|
||||||
Serie {top.def.label.toLowerCase()}: {top.valore}
|
Serie {top.def.label.toLowerCase()}: {top.valore}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
|
<p className="text-xs 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-[13px] font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
💩 Sondaggio pre-partita
|
💩 Sondaggio pre-partita
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs 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-[13px] font-bold uppercase text-muted-foreground">
|
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||||
{dellaPartita.length} risposte
|
{dellaPartita.length} risposte
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
|
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-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
"min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||||
mia?.quantita === n
|
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-[13px]">
|
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
<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-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
|
{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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Palloni
|
Palloni
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
|
<span className="flex items-center gap-1.5 text-sm font-bold leading-tight">
|
||||||
{isPending || assegna.isPending ? (
|
{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-[13px] font-bold uppercase text-muted-foreground">
|
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs font-bold uppercase text-muted-foreground">
|
||||||
proposto
|
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-[15px] transition-colors",
|
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
|
||||||
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-[13px]" />
|
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
|
||||||
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
|
<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-[13px] font-bold uppercase text-foreground disabled:opacity-50"
|
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{avviso ? (
|
{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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
<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-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{mio ? "Cambia voto" : "Vota"}
|
{mio ? "Cambia voto" : "Vota"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-[13px]">
|
<p className="mt-2 text-xs">
|
||||||
{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-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||||
) : !puoVotare ? (
|
) : !puoVotare ? (
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Vota chi era presente a questa partita.
|
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-[13px] font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||||
mio?.votato_id === g.id
|
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-[13px] font-semibold">
|
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs 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-[13px] font-semibold text-muted-foreground">
|
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs font-semibold text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<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,16 +79,14 @@ 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-[15px] font-bold leading-tight">
|
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
|
||||||
{cat.nome}
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
</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-[13px] font-bold uppercase",
|
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||||
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -107,7 +105,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-[13px] font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||||
mio?.votato_id === g.id
|
mio?.votato_id === g.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-foreground",
|
: "bg-secondary text-foreground",
|
||||||
@@ -120,17 +118,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border-t border-border px-3 py-2">
|
<div className="border-t border-border px-3 py-2">
|
||||||
{totale === 0 ? (
|
{totale === 0 ? (
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs 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-[13px] font-bold">
|
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
||||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
<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-[13px] text-muted-foreground">
|
<p className="text-xs 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-[15px]"
|
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||||
>
|
>
|
||||||
<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-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
<p className="mt-1 text-xs 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-[15px] text-primary-foreground/80">{sottotitolo}</p>
|
<p className="mt-1 truncate text-sm 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-[15px]";
|
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
|
* 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs 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-[13px] font-bold uppercase",
|
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs 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-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
|
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
|
||||||
</div>
|
</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-[15px] text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import {
|
|||||||
Repeat,
|
Repeat,
|
||||||
Lock,
|
Lock,
|
||||||
Ghost,
|
Ghost,
|
||||||
|
Zap,
|
||||||
Rocket,
|
Rocket,
|
||||||
|
Anchor,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
AlarmClock,
|
AlarmClock,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
@@ -112,6 +114,15 @@ export const badgeDefs: BadgeDef[] = [
|
|||||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||||
valore: (g) => g.serieAllenamenti,
|
valore: (g) => g.serieAllenamenti,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "serie-conferme",
|
||||||
|
nome: "Risposta lampo",
|
||||||
|
descrizione: "Conferme rapide agli eventi: la squadra sa subito che ci sei.",
|
||||||
|
unita: "conferme entro 24h",
|
||||||
|
icon: Zap,
|
||||||
|
soglie: { bronzo: 3, argento: 8, oro: 15 },
|
||||||
|
valore: (g) => g.serieConferme,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */
|
/** Badge segreti: non compaiono nella UI finché non vengono sbloccati. */
|
||||||
@@ -130,6 +141,18 @@ export const badgeSegreti: BadgeDef[] = [
|
|||||||
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
|
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
|
||||||
celebrazione: "Nei momenti caldi ci sei sempre.",
|
celebrazione: "Nei momenti caldi ci sei sempre.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "s-mai-forfait",
|
||||||
|
nome: "Mai un forfait",
|
||||||
|
descrizione:
|
||||||
|
"Sbloccato con 10 conferme rapide consecutive e 15 presenze: su di te la squadra può contare a occhi chiusi.",
|
||||||
|
unita: "requisito nascosto",
|
||||||
|
icon: Anchor,
|
||||||
|
segreto: true,
|
||||||
|
soglie: { bronzo: 1, argento: 1, oro: 1 },
|
||||||
|
valore: (g) => (g.serieConferme >= 10 && g.presenze >= 15 ? 1 : 0),
|
||||||
|
celebrazione: "Su di te la squadra può contare a occhi chiusi.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "s-infermeria",
|
id: "s-infermeria",
|
||||||
nome: "Cliente VIP dell'Infermeria",
|
nome: "Cliente VIP dell'Infermeria",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type Giocatore = {
|
|||||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||||
serieAllenamenti: number;
|
serieAllenamenti: number;
|
||||||
seriePartite: number;
|
seriePartite: number;
|
||||||
|
serieConferme: number;
|
||||||
/** MVP eletti dalla squadra. */
|
/** MVP eletti dalla squadra. */
|
||||||
mvp: number;
|
mvp: number;
|
||||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||||
@@ -108,6 +109,7 @@ export const giocatori: Giocatore[] = rosaCSI
|
|||||||
streak: 0,
|
streak: 0,
|
||||||
serieAllenamenti: 0,
|
serieAllenamenti: 0,
|
||||||
seriePartite: 0,
|
seriePartite: 0,
|
||||||
|
serieConferme: 0,
|
||||||
mvp: 0,
|
mvp: 0,
|
||||||
mediaVoto: 0,
|
mediaVoto: 0,
|
||||||
votiPagella: 0,
|
votiPagella: 0,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { eventoVisibile, useEventi } from "./eventi";
|
|
||||||
import { usePuoGestireEventi } from "./ruoli";
|
|
||||||
import { useGiocatoreId } from "./user-store";
|
|
||||||
|
|
||||||
/** Gli eventi che l'utente corrente può vedere (calendario, home, elenco). */
|
|
||||||
export function useEventiVisibili() {
|
|
||||||
const { eventi, ...resto } = useEventi();
|
|
||||||
const giocatoreId = useGiocatoreId();
|
|
||||||
const vedeTutto = usePuoGestireEventi();
|
|
||||||
return {
|
|
||||||
...resto,
|
|
||||||
eventi: eventi.filter((e) => eventoVisibile(e, giocatoreId, vedeTutto)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+2
-26
@@ -90,25 +90,6 @@ export function useEventi() {
|
|||||||
return { ...query, eventi: query.data ?? [] };
|
return { ...query, eventi: query.data ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Il giocatore è tra i convocati (elenco vuoto = tutta la rosa). */
|
|
||||||
export function eConvocato(evento: Pick<Evento, "convocati">, giocatoreId: string): boolean {
|
|
||||||
return evento.convocati.length === 0 || evento.convocati.includes(giocatoreId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Chi vede un evento: i convocati; se `convocati` è vuoto, tutta la rosa. Chi gestisce gli
|
|
||||||
* eventi (admin e allenatori) li vede sempre, convocato o no.
|
|
||||||
*/
|
|
||||||
export function eventoVisibile(
|
|
||||||
evento: Pick<Evento, "convocati">,
|
|
||||||
giocatoreId: string | null,
|
|
||||||
vedeTutto: boolean,
|
|
||||||
): boolean {
|
|
||||||
if (vedeTutto) return true;
|
|
||||||
if (evento.convocati.length === 0) return true;
|
|
||||||
return giocatoreId !== null && eConvocato(evento, giocatoreId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEvento(id: string) {
|
export function useEvento(id: string) {
|
||||||
const { eventi, ...resto } = useEventi();
|
const { eventi, ...resto } = useEventi();
|
||||||
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
||||||
@@ -217,11 +198,6 @@ export function compleanniEventi(
|
|||||||
|
|
||||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||||
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
||||||
return rosaConvocata(rosa, evento?.convocati ?? []);
|
if (!evento || evento.convocati.length === 0) return rosa;
|
||||||
}
|
return rosa.filter((g) => evento.convocati.includes(g.id));
|
||||||
|
|
||||||
/** Restringe una rosa ai convocati indicati: elenco vuoto = tutta la rosa. */
|
|
||||||
export function rosaConvocata<T extends { id: string }>(rosa: T[], convocati: string[]): T[] {
|
|
||||||
if (convocati.length === 0) return rosa;
|
|
||||||
return rosa.filter((g) => convocati.includes(g.id));
|
|
||||||
}
|
}
|
||||||
@@ -222,6 +222,40 @@ export function useSalvaDatiSquadra() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
|
||||||
|
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
|
||||||
|
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
|
||||||
|
* tesserato.
|
||||||
|
*/
|
||||||
|
export function useSalvaTesseramento() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
|
||||||
|
const dati = {
|
||||||
|
numero_tessera: input.dati.numeroTessera?.trim() || null,
|
||||||
|
data_tessera: input.dati.dataTessera || null,
|
||||||
|
};
|
||||||
|
const { error } = await supabaseNuoveTabelle
|
||||||
|
.from("giocatori_squadra")
|
||||||
|
.update(dati)
|
||||||
|
.eq("id", input.giocatoreId);
|
||||||
|
if (error) throw error;
|
||||||
|
return {
|
||||||
|
giocatoreId: input.giocatoreId,
|
||||||
|
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onSuccess: (input) => {
|
||||||
|
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||||
|
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1.
|
* 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,10 +24,7 @@ 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"
|
|
||||||
| "presenza_modificata";
|
|
||||||
|
|
||||||
export type NotificaUtente = {
|
export type NotificaUtente = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
+17
-239
@@ -14,25 +14,6 @@ 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 = {
|
||||||
@@ -50,24 +31,6 @@ 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(
|
||||||
@@ -86,30 +49,16 @@ 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) {
|
||||||
* Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da
|
const delMese = ctx.eventi.filter(
|
||||||
* `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non
|
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||||
* è convocato non occupa un posto, quindi la sua assenza non pesa.
|
);
|
||||||
*/
|
if (delMese.length === 0 || rosaSize === 0) return 0;
|
||||||
function percentualePresenze(
|
const posti = delMese.length * rosaSize;
|
||||||
ctx: ContestoObiettivi,
|
const presenti = delMese.reduce((s, e) => {
|
||||||
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] ?? {};
|
||||||
presenti += attesi.filter((g) => {
|
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
||||||
const stato = risposte[g.id];
|
}, 0);
|
||||||
return stato === "presente" || stato === "ritardo";
|
|
||||||
}).length;
|
|
||||||
}
|
|
||||||
if (posti === 0) return 0;
|
|
||||||
return Math.round((presenti / posti) * 100);
|
return Math.round((presenti / posti) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +79,7 @@ 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);
|
||||||
@@ -138,35 +88,12 @@ 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: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
|
valore: percentualePresenzeMese(ctx, rosa.length, 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",
|
||||||
@@ -178,55 +105,16 @@ 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: "Presenze collettive",
|
titolo: "250 presenze complessive",
|
||||||
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
|
descrizione: "Somma delle presenze di tutta la rosa in stagione",
|
||||||
valore: percentualePresenze(ctx, rosa),
|
valore: somma((g) => g.presenze),
|
||||||
target: 90,
|
target: 250,
|
||||||
unita: "%",
|
unita: "presenze",
|
||||||
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",
|
||||||
@@ -237,19 +125,6 @@ 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",
|
||||||
@@ -260,16 +135,6 @@ 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",
|
||||||
@@ -280,21 +145,6 @@ 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",
|
||||||
@@ -305,22 +155,6 @@ 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",
|
||||||
@@ -331,22 +165,6 @@ 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",
|
||||||
@@ -357,56 +175,16 @@ 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",
|
||||||
// Conta solo quando l'ora dell'evento è arrivata, non quando viene definito.
|
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
||||||
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.",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
@@ -155,6 +155,31 @@ export function serieConsecutiva(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORE_24 = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serie di conferme rapide: risposte arrivate entro 24 ore dalla convocazione
|
||||||
|
* (`creatoIl` dell'evento). Gli eventi senza istante di creazione — quelli generati
|
||||||
|
* dal client, non salvati a database — non spezzano la serie: vengono saltati.
|
||||||
|
*/
|
||||||
|
export function serieConferme(
|
||||||
|
giocatoreId: string,
|
||||||
|
eventi: Evento[],
|
||||||
|
tempi: MappaTempiRisposta,
|
||||||
|
oggi: string = dataOggi(),
|
||||||
|
): number {
|
||||||
|
return serieSu(
|
||||||
|
giocatoreId,
|
||||||
|
eventi.filter((e) => e.creatoIl),
|
||||||
|
oggi,
|
||||||
|
undefined,
|
||||||
|
(e) => {
|
||||||
|
const risposto = tempi[e.id]?.[giocatoreId];
|
||||||
|
return risposto !== undefined && Date.parse(risposto) - Date.parse(e.creatoIl!) <= ORE_24;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
|
/** Scorre gli eventi già passati in ordine di data applicando la regola delle serie. */
|
||||||
function serieSu(
|
function serieSu(
|
||||||
giocatoreId: string,
|
giocatoreId: string,
|
||||||
@@ -203,7 +228,7 @@ export function usePresenzeEvento(eventoId: string) {
|
|||||||
*
|
*
|
||||||
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
* - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il`
|
||||||
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
|
* non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima,
|
||||||
* e un ripensamento non deve cambiare l'istante della prima risposta;
|
* e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h";
|
||||||
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
* - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore
|
||||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+15
-2
@@ -12,6 +12,7 @@ import { useEventi } from "./eventi";
|
|||||||
import {
|
import {
|
||||||
contaPartiteGiocate,
|
contaPartiteGiocate,
|
||||||
contaPresenzeGiocatore,
|
contaPresenzeGiocatore,
|
||||||
|
serieConferme,
|
||||||
serieConsecutiva,
|
serieConsecutiva,
|
||||||
totaliEventiGiocatore,
|
totaliEventiGiocatore,
|
||||||
useRispostePresenze,
|
useRispostePresenze,
|
||||||
@@ -67,7 +68,7 @@ export function useRosa(): Giocatore[] {
|
|||||||
const { salvati: turniSalvati } = useTurniPalloni();
|
const { salvati: turniSalvati } = useTurniPalloni();
|
||||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventi();
|
||||||
const { presenze: mappaPresenze } = useRispostePresenze();
|
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||||
|
|
||||||
const votiMvp = voti.data;
|
const votiMvp = voti.data;
|
||||||
|
|
||||||
@@ -93,6 +94,7 @@ export function useRosa(): Giocatore[] {
|
|||||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||||
|
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||||
mvp: mvpVinti[g.id] ?? 0,
|
mvp: mvpVinti[g.id] ?? 0,
|
||||||
mediaVoto: medie[g.id]?.media ?? 0,
|
mediaVoto: medie[g.id]?.media ?? 0,
|
||||||
votiPagella: medie[g.id]?.voti ?? 0,
|
votiPagella: medie[g.id]?.voti ?? 0,
|
||||||
@@ -103,7 +105,18 @@ export function useRosa(): Giocatore[] {
|
|||||||
infortuni: infortuni[g.id] ?? 0,
|
infortuni: infortuni[g.id] ?? 0,
|
||||||
ritardi: ritardi[g.id] ?? 0,
|
ritardi: ritardi[g.id] ?? 0,
|
||||||
}));
|
}));
|
||||||
}, [squadra, votiMvp, pagelle, cacche, turniSalvati, infortuni, ritardi, eventi, mappaPresenze]);
|
}, [
|
||||||
|
squadra,
|
||||||
|
votiMvp,
|
||||||
|
pagelle,
|
||||||
|
cacche,
|
||||||
|
turniSalvati,
|
||||||
|
infortuni,
|
||||||
|
ritardi,
|
||||||
|
eventi,
|
||||||
|
mappaPresenze,
|
||||||
|
tempi,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Criteri di ordinamento della classifica interna di Squadra. */
|
/** Criteri di ordinamento della classifica interna di Squadra. */
|
||||||
|
|||||||
+10
-2
@@ -1,8 +1,8 @@
|
|||||||
import { Dumbbell, Swords } from "lucide-react";
|
import { Dumbbell, Swords, Zap } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { Giocatore } from "./crapp-data";
|
import type { Giocatore } from "./crapp-data";
|
||||||
|
|
||||||
export type SerieTipo = "allenamenti" | "partite";
|
export type SerieTipo = "allenamenti" | "partite" | "conferme";
|
||||||
|
|
||||||
export type SerieDef = {
|
export type SerieDef = {
|
||||||
tipo: SerieTipo;
|
tipo: SerieTipo;
|
||||||
@@ -31,6 +31,14 @@ export const serieDefs: SerieDef[] = [
|
|||||||
traguardi: [2, 5, 8, 12],
|
traguardi: [2, 5, 8, 12],
|
||||||
valore: (g) => g.seriePartite,
|
valore: (g) => g.seriePartite,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
tipo: "conferme",
|
||||||
|
label: "Conferme 24h",
|
||||||
|
descrizione: "Risposte date entro 24 ore dalla convocazione",
|
||||||
|
icon: Zap,
|
||||||
|
traguardi: [3, 8, 15, 20],
|
||||||
|
valore: (g) => g.serieConferme,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export type SerieStato = {
|
export type SerieStato = {
|
||||||
|
|||||||
@@ -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-[15px] text-muted-foreground">
|
<p className="mt-2 text-sm 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-[15px] font-semibold text-primary-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Torna alla home
|
Torna alla home
|
||||||
</Link>
|
</Link>
|
||||||
@@ -43,7 +43,7 @@ function NotFoundComponent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
|
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })
|
|||||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
<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-[15px] text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.
|
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: unknown; 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-[15px] font-semibold text-primary-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||||
>
|
>
|
||||||
Riprova
|
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-[15px] font-semibold text-foreground"
|
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-sm font-semibold text-foreground"
|
||||||
>
|
>
|
||||||
Torna alla home
|
Torna alla home
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+95
-24
@@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -32,9 +33,11 @@ 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";
|
||||||
@@ -107,11 +110,18 @@ 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,
|
||||||
@@ -121,7 +131,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 né ruolo in campo (DD-034).
|
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
|
||||||
const allenatore = isAllenatore(g);
|
const allenatore = isAllenatore(g);
|
||||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||||
|
|
||||||
@@ -157,6 +167,16 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confermaTesseramento() {
|
||||||
|
try {
|
||||||
|
await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente });
|
||||||
|
setTesseramento(null);
|
||||||
|
toast.success("Tesseramento aggiornato");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(messaggioErrore(e, "Salvataggio non riuscito"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confermaProfilo() {
|
async function confermaProfilo() {
|
||||||
try {
|
try {
|
||||||
await salvaProfilo.mutateAsync(profiloCorrente);
|
await salvaProfilo.mutateAsync(profiloCorrente);
|
||||||
@@ -184,7 +204,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-[15px] uppercase tracking-wide">Dati squadra</h3>
|
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Campo label="Nome">
|
<Campo label="Nome">
|
||||||
<input
|
<input
|
||||||
@@ -244,14 +264,54 @@ 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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Salva dati squadra
|
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-[15px] uppercase tracking-wide">Dati personali</h3>
|
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
|
||||||
<CampiAllenatore
|
<CampiAllenatore
|
||||||
corrente={profiloCorrente}
|
corrente={profiloCorrente}
|
||||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||||
@@ -268,12 +328,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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Salva dati personali
|
Salva dati personali
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-[13px]">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
|
||||||
<span className="min-w-0 text-muted-foreground">
|
<span className="min-w-0 text-muted-foreground">
|
||||||
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
||||||
</span>
|
</span>
|
||||||
@@ -344,7 +404,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-[13px] font-bold uppercase disabled:opacity-60",
|
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
|
||||||
statoClasse[stato],
|
statoClasse[stato],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -382,12 +442,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-[15px] tabular-nums">
|
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||||
{allenatore ? "All" : numero}
|
{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-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{ruoloVisibile(g)} · profilo {perc}%
|
{ruoloVisibile(g)} · profilo {perc}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +486,15 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -480,7 +549,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-[15px] font-bold uppercase"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||||
>
|
>
|
||||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
||||||
</button>
|
</button>
|
||||||
@@ -489,7 +558,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-[15px] uppercase tracking-wide">
|
<h3 className="font-display text-sm uppercase tracking-wide">
|
||||||
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
||||||
</h3>
|
</h3>
|
||||||
<Campo label="Tipo">
|
<Campo label="Tipo">
|
||||||
@@ -563,7 +632,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-[13px] font-bold uppercase"
|
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -571,7 +640,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-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Aggiungi
|
Aggiungi
|
||||||
</button>
|
</button>
|
||||||
@@ -598,7 +667,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
|||||||
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
|
<div className="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-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -606,7 +675,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-[13px] font-bold text-success-foreground disabled:opacity-50"
|
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
||||||
</button>
|
</button>
|
||||||
@@ -653,7 +722,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-[15px] text-muted-foreground shadow-card">
|
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
<Lock className="h-4 w-4 shrink-0" />
|
<Lock className="h-4 w-4 shrink-0" />
|
||||||
Riservata agli amministratori della squadra.
|
Riservata agli amministratori della squadra.
|
||||||
</p>
|
</p>
|
||||||
@@ -674,6 +743,7 @@ 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 = (
|
||||||
<>
|
<>
|
||||||
@@ -685,11 +755,12 @@ 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-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||||
</button>
|
</button>
|
||||||
@@ -698,7 +769,7 @@ function Dashboard() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const contenutoProfili = isPending ? (
|
const contenutoProfili = isPending ? (
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Caricamento…
|
Caricamento…
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -715,7 +786,7 @@ function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{allenatori.length > 0 ? (
|
{allenatori.length > 0 ? (
|
||||||
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
|
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
|
||||||
) : null}
|
) : null}
|
||||||
{allenatori.map((g, i) => (
|
{allenatori.map((g, i) => (
|
||||||
<SchedaGiocatore
|
<SchedaGiocatore
|
||||||
@@ -748,7 +819,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-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||||
</button>
|
</button>
|
||||||
@@ -764,7 +835,7 @@ function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
|
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
||||||
!attiva && "text-muted-foreground",
|
!attiva && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -784,7 +855,7 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Caricamento…
|
Caricamento…
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -838,7 +909,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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{inviaNotifica.isPending ? (
|
{inviaNotifica.isPending ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -850,7 +921,7 @@ function Dashboard() {
|
|||||||
<DrawerClose asChild>
|
<DrawerClose asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,9 +43,7 @@ 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-[15px] text-muted-foreground">
|
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
|
||||||
Allenamento non trovato
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,14 +59,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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs 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-[15px] text-muted-foreground">
|
<div className="mt-4 space-y-2 text-sm 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>
|
||||||
@@ -78,12 +76,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-[15px]">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||||
{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-[13px] font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,12 +91,7 @@ function AllenamentoDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
eventoId={evento.id}
|
|
||||||
data={evento.data}
|
|
||||||
tipo={evento.tipo}
|
|
||||||
convocati={evento.convocati}
|
|
||||||
/>
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -107,34 +107,34 @@ function Benvenuto() {
|
|||||||
|
|
||||||
{!pronta ? null : !utenteId ? (
|
{!pronta ? null : !utenteId ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
Accedi con il tuo account Google per collegare il profilo giocatore.
|
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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<LogIn className="h-4 w-4" /> Accedi con Google
|
<LogIn className="h-4 w-4" /> Accedi con Google
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : erroreCollegamento ? (
|
) : erroreCollegamento ? (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
|
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-[15px] font-bold uppercase shadow-card"
|
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" /> Esci
|
<LogOut className="h-4 w-4" /> Esci
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : inAttesaCollegamento ? (
|
) : inAttesaCollegamento ? (
|
||||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||||
Ti stiamo collegando al tuo profilo...
|
Ti stiamo collegando al tuo profilo...
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+14
-24
@@ -8,8 +8,7 @@ import { molla, proietta } from "@/lib/molla";
|
|||||||
import { useMotoRidotto } from "@/lib/motion";
|
import { useMotoRidotto } from "@/lib/motion";
|
||||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
import { compleanniEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
@@ -57,7 +56,7 @@ function Calendario() {
|
|||||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||||
const puoGestire = usePuoGestireEventi();
|
const puoGestire = usePuoGestireEventi();
|
||||||
const { eventi } = useEventiVisibili();
|
const { eventi } = useEventi();
|
||||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||||
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||||
@@ -91,13 +90,6 @@ function Calendario() {
|
|||||||
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
|
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
|
||||||
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
|
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
|
||||||
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
|
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
|
||||||
// Solo i compleanni del mese in corso non ancora passati (oggi compreso), a prescindere dal mese
|
|
||||||
// mostrato nella griglia.
|
|
||||||
const compleanniMeseCorrente =
|
|
||||||
oggi && oggiIso
|
|
||||||
? compleanniEventi(rosa, oggi.anno).filter((c) => c.data.startsWith(oggiIso.slice(0, 7)))
|
|
||||||
: [];
|
|
||||||
const prossimiCompleanni = oggiIso ? compleanniMeseCorrente.filter((c) => c.data >= oggiIso) : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -127,7 +119,7 @@ function Calendario() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||||
{giorniIT.map((g, i) => (
|
{giorniIT.map((g, i) => (
|
||||||
<span key={i}>{g}</span>
|
<span key={i}>{g}</span>
|
||||||
))}
|
))}
|
||||||
@@ -200,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-[15px] font-semibold",
|
"relative grid aspect-square place-items-center rounded-xl text-sm 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",
|
||||||
@@ -232,10 +224,10 @@ function Calendario() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
Scorri a destra o sinistra per cambiare mese.
|
Scorri a destra o sinistra per cambiare mese.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
|
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1">
|
<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>
|
||||||
@@ -256,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-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[14px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
|
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
|
||||||
</Link>
|
</Link>
|
||||||
@@ -271,22 +263,20 @@ 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-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Nessun evento in programma
|
Nessun evento in programma
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Prossimi compleanni">
|
<Section titolo="Compleanni">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{prossimiCompleanni.length > 0 ? (
|
{compleanniMese.length > 0 ? (
|
||||||
prossimiCompleanni.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-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
{compleanniMeseCorrente.length > 0
|
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
|
||||||
? "Nessun altro compleanno questo mese"
|
|
||||||
: "Nessun compleanno questo mese"}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -310,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-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Nessun evento in questa data
|
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-[13px] font-bold uppercase text-muted-foreground">
|
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
||||||
<span>#</span>
|
<span>#</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-[13px] text-muted-foreground">{vuoto}</p>
|
<p className="px-3 py-4 text-center text-xs 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-[15px] last:border-0",
|
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
||||||
noi && "bg-accent/10",
|
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-[13px] text-muted-foreground">{r.giocate}</span>
|
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
|
||||||
<span className="text-center text-[13px] tabular-nums text-muted-foreground">
|
<span className="text-center text-xs 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-[13px]",
|
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
|
||||||
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-[15px] font-bold text-foreground">Coppa</h3>
|
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
|
||||||
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
||||||
<h3 className="mb-2 mt-4 px-1 text-[15px] font-bold text-foreground">Girone</h3>
|
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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-[13px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
||||||
Nessuna partita disponibile.
|
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-[15px] font-bold text-foreground">Stagione {stagione}</h3>
|
<h3 className="px-1 text-sm 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-[15px] font-bold">{casa.nome}</p>
|
<p className="truncate text-sm 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-[15px] font-bold">{trasferta.nome}</p>
|
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs 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-[13px] font-semibold tabular-nums",
|
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||||
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
|
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-[13px] font-bold uppercase text-accent">
|
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+26
-28
@@ -1,4 +1,3 @@
|
|||||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
daCategoria,
|
daCategoria,
|
||||||
eventoVuoto,
|
eventoVuoto,
|
||||||
useEliminaEvento,
|
useEliminaEvento,
|
||||||
|
useEventi,
|
||||||
useSalvaEvento,
|
useSalvaEvento,
|
||||||
type CategoriaEvento,
|
type CategoriaEvento,
|
||||||
type Evento,
|
type Evento,
|
||||||
@@ -65,7 +65,7 @@ function GestioneEventi() {
|
|||||||
const io = useGiocatoreBase();
|
const io = useGiocatoreBase();
|
||||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||||
const puoGestire = usePuoGestireEventi();
|
const puoGestire = usePuoGestireEventi();
|
||||||
const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
|
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const salva = useSalvaEvento();
|
const salva = useSalvaEvento();
|
||||||
const elimina = useEliminaEvento();
|
const elimina = useEliminaEvento();
|
||||||
@@ -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-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Solo i referenti della squadra possono creare o modificare gli eventi.
|
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-[13px] font-bold text-muted-foreground">
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||||
{giorniIT.map((g, i) => (
|
{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-[15px] font-semibold transition-transform active:scale-90",
|
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
||||||
haEventi
|
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-[13px] text-muted-foreground">
|
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
|
||||||
Carico gli eventi…
|
Carico gli eventi…
|
||||||
</p>
|
</p>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="mt-3 space-y-2 text-center text-[15px]">
|
<div className="mt-3 space-y-2 text-center text-sm">
|
||||||
<p className="text-destructive">
|
<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-[13px] font-bold uppercase"
|
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
|
||||||
>
|
>
|
||||||
Riprova
|
Riprova
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
|
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-[13px] font-bold uppercase transition-colors",
|
"rounded-full py-2 text-xs 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,17 +337,15 @@ function GestioneEventi() {
|
|||||||
</Campo>
|
</Campo>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Giorno
|
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-[15px] font-semibold">
|
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
|
||||||
{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-[13px] font-bold uppercase text-accent active:scale-95"
|
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
|
||||||
>
|
>
|
||||||
Cambia
|
Cambia
|
||||||
</button>
|
</button>
|
||||||
@@ -396,7 +394,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-[13px] font-bold uppercase transition-colors",
|
"flex-1 rounded-full py-2 text-xs 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",
|
||||||
@@ -435,7 +433,7 @@ function GestioneEventi() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||||
scelto
|
scelto
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-secondary text-muted-foreground",
|
: "bg-secondary text-muted-foreground",
|
||||||
@@ -452,7 +450,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-[15px] font-bold uppercase"
|
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -460,7 +458,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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
|
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
|
||||||
</button>
|
</button>
|
||||||
@@ -491,8 +489,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-[15px] font-bold leading-tight">{e.titolo}</p>
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{e.ora} · {e.luogo || "luogo da definire"}
|
{e.ora} · {e.luogo || "luogo da definire"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,7 +513,7 @@ function GestioneEventi() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-2xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
Nessun evento in programma
|
Nessun evento in programma
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -523,7 +521,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-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||||
</button>
|
</button>
|
||||||
@@ -546,14 +544,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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
{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-[15px] font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -575,14 +573,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-[15px] font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
|
{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-[15px] font-bold uppercase text-foreground"
|
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||||
>
|
>
|
||||||
Annulla
|
Annulla
|
||||||
</button>
|
</button>
|
||||||
@@ -608,7 +606,7 @@ function Interruttore({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors",
|
"rounded-full px-3 py-1.5 text-xs font-bold uppercase transition-colors",
|
||||||
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
|
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-[15px] text-muted-foreground">Evento non trovato</p>
|
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs 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-[15px] text-muted-foreground">
|
<div className="mt-4 space-y-2 text-sm 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-[15px]">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||||
{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-[13px] font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,12 +88,7 @@ function EventoDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
eventoId={evento.id}
|
|
||||||
data={evento.data}
|
|
||||||
tipo={evento.tipo}
|
|
||||||
convocati={evento.convocati}
|
|
||||||
/>
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+30
-46
@@ -9,10 +9,8 @@ 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 { eConvocato, type Evento } from "@/lib/eventi";
|
import { useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
|
||||||
import { useRispostePresenze } from "@/lib/presenze";
|
import { useRispostePresenze } from "@/lib/presenze";
|
||||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||||
@@ -48,14 +46,14 @@ function Index() {
|
|||||||
const base = useGiocatoreBase();
|
const base = useGiocatoreBase();
|
||||||
const giocatore = useIo();
|
const giocatore = useIo();
|
||||||
const allenatore = isAllenatore(base);
|
const allenatore = isAllenatore(base);
|
||||||
const { eventi } = useEventiVisibili();
|
const { eventi } = useEventi();
|
||||||
const { presenze } = useRispostePresenze();
|
const { presenze } = useRispostePresenze();
|
||||||
const oggi = new Date().toISOString().slice(0, 10);
|
const oggi = new Date().toISOString().slice(0, 10);
|
||||||
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
||||||
const prossimo = prossimi[0] ?? null;
|
const prossimo = prossimi[0] ?? null;
|
||||||
const daConfermare = prossimi
|
const daConfermare = prossimi
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter((e) => !giocatore || (eConvocato(e, giocatore.id) && !presenze[e.id]?.[giocatore.id]));
|
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]);
|
||||||
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
|
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
|
||||||
const { data: csi } = useCsi();
|
const { data: csi } = useCsi();
|
||||||
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||||
@@ -87,7 +85,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-[13px] font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||||
Ciao {nome.split(" ")[0]}
|
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>
|
||||||
@@ -101,13 +99,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-[13px] uppercase text-primary-foreground/80">In classifica</p>
|
<p className="text-xs 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-[13px] uppercase text-primary-foreground/80">Bilancio W-L</p>
|
<p className="text-xs 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">
|
||||||
@@ -115,7 +113,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-[13px] uppercase text-primary-foreground/80">Streak</p>
|
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +131,7 @@ function Index() {
|
|||||||
azione={
|
azione={
|
||||||
<Link
|
<Link
|
||||||
to="/calendario"
|
to="/calendario"
|
||||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Calendario <ChevronRight className="h-4 w-4" />
|
Calendario <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -142,7 +140,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-[13px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||||
Nessun impegno in programma.
|
Nessun impegno in programma.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -157,7 +155,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-[13px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||||
Nient'altro da confermare: sei in pari.
|
Nient'altro da confermare: sei in pari.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +170,7 @@ function Index() {
|
|||||||
<Link
|
<Link
|
||||||
to="/classifica"
|
to="/classifica"
|
||||||
search={{ tab: "storico" }}
|
search={{ tab: "storico" }}
|
||||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Storico <ChevronRight className="h-4 w-4" />
|
Storico <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -184,10 +182,8 @@ 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-[15px] font-bold">
|
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
|
||||||
CRAP Volley vs {ultima.avversario}
|
<p className="text-xs text-muted-foreground">
|
||||||
</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>
|
||||||
@@ -200,7 +196,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-[13px] font-semibold tabular-nums"
|
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{p[0]}-{p[1]}
|
{p[0]}-{p[1]}
|
||||||
</span>
|
</span>
|
||||||
@@ -221,7 +217,7 @@ function Index() {
|
|||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||||
Nessun risultato disponibile.
|
Nessun risultato disponibile.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -234,39 +230,27 @@ function Index() {
|
|||||||
azione={
|
azione={
|
||||||
<Link
|
<Link
|
||||||
to="/squadra"
|
to="/squadra"
|
||||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||||
>
|
>
|
||||||
Tutti <ChevronRight className="h-4 w-4" />
|
Tutti <ChevronRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{obiettivo ? (
|
{obiettivo ? (
|
||||||
<ObiettivoDrawer obiettivo={obiettivo}>
|
<Card>
|
||||||
<button
|
<div className="flex items-center gap-2 text-sm font-bold">
|
||||||
type="button"
|
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||||
aria-label={`Dettaglio obiettivo: ${obiettivo.titolo}`}
|
</div>
|
||||||
className="block w-full text-left"
|
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||||
>
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
<Card>
|
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||||
<div className="flex items-center gap-2 text-[15px] font-bold">
|
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||||
<span className="text-base leading-none">{obiettivo.emoji}</span>{" "}
|
</p>
|
||||||
{obiettivo.titolo}
|
<p className="mt-1 text-xs font-semibold text-accent">
|
||||||
</div>
|
{microcopyObiettivo(obiettivo)}
|
||||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
</p>
|
||||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
</Card>
|
||||||
{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-[15px] text-muted-foreground">Partita non trovata</p>
|
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{partita.casa ? "In casa" : "Fuori casa"}
|
{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-[13px] font-semibold tabular-nums"
|
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{noi}-{loro}
|
{noi}-{loro}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+14
-21
@@ -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-[15px] text-muted-foreground">Partita non trovata</p>
|
<p className="mt-8 text-center text-sm 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{casa ? "In casa" : "Fuori casa"}
|
{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-[15px] text-muted-foreground">
|
<div className="mt-4 space-y-2 text-sm 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-[15px]">
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||||
{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-[13px] font-semibold">
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||||
<Users className="h-4 w-4" />
|
<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-[15px] font-bold">CRAP Volley</span>
|
<span className="text-sm 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-[15px] font-bold">{avversario}</span>
|
<span className="text-right text-sm 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,9 +183,7 @@ 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-[13px] font-semibold text-muted-foreground">
|
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
|
||||||
Set {i + 1}
|
|
||||||
</span>
|
|
||||||
<span className="font-display text-lg">{loro}</span>
|
<span className="font-display text-lg">{loro}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -215,7 +213,7 @@ function PartitaDetail() {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Section titolo="In programma">
|
<Section titolo="In programma">
|
||||||
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||||
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
||||||
risultato.
|
risultato.
|
||||||
</p>
|
</p>
|
||||||
@@ -250,13 +248,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-[13px] font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
{t.l}
|
{t.l}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Dettaglio giocatori (uso interno allenatori)
|
Dettaglio giocatori (uso interno allenatori)
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
@@ -266,7 +264,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-[13px]"
|
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||||
#{g.numero} {g.nome}
|
#{g.numero} {g.nome}
|
||||||
@@ -287,7 +285,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-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV
|
<Download className="h-4 w-4" /> Esporta CSV
|
||||||
</button>
|
</button>
|
||||||
@@ -297,12 +295,7 @@ function PartitaDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
eventoId={evento.id}
|
|
||||||
data={evento.data}
|
|
||||||
tipo={evento.tipo}
|
|
||||||
convocati={evento.convocati}
|
|
||||||
/>
|
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+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-[13px] font-bold uppercase tracking-wide"
|
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||||
>
|
>
|
||||||
Cambia immagine profilo
|
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-[15px] disabled:opacity-60"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate">Notifiche</span>
|
<span className="block truncate">Notifiche</span>
|
||||||
<span className="block text-[13px] text-muted-foreground">
|
<span className="block text-xs 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-[15px] disabled:opacity-60"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block truncate">Email</span>
|
<span className="block truncate">Email</span>
|
||||||
<span className="block text-[13px] text-muted-foreground">
|
<span className="block text-xs 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-[15px] transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
<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-[15px] transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
<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-[15px] transition-colors hover:bg-accent/5"
|
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
<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-[13px] text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
<p className="text-center text-xs 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-[15px] font-bold text-destructive shadow-card"
|
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<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-[15px] text-muted-foreground">{testo}</p>
|
<p className="mt-2 text-sm 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-[15px] font-bold uppercase"
|
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm 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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
|
{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-[13px] font-bold uppercase tracking-wide">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-xs font-bold uppercase tracking-wide">
|
||||||
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
|
<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-[13px] font-bold disabled:opacity-40"
|
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
<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-[13px] font-bold"
|
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
|
||||||
>
|
>
|
||||||
<LogOut className="h-3.5 w-3.5" /> Rilascia
|
<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-[13px] uppercase text-primary-foreground/60">CRAP Volley</p>
|
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
|
||||||
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
|
<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-[13px] uppercase text-primary-foreground/60">
|
<p className="truncate text-xs 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-[13px] font-semibold tabular-nums"
|
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{p[0]}-{p[1]}
|
{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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
1. Tocca il giocatore
|
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-[13px] font-bold leading-tight">{g.nome}</p>
|
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[13px] tabular-nums",
|
"text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
2. Tocca l'azione
|
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-[15px] font-bold uppercase shadow-card transition-transform active:scale-95",
|
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
|
||||||
azioniMeta[tipo].className,
|
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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs 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-[13px] text-muted-foreground">Nessuna azione registrata.</p>
|
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-1.5">
|
<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-[13px]",
|
"flex items-center justify-between gap-2 px-1 text-xs",
|
||||||
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-[13px] font-bold uppercase",
|
"shrink-0 rounded-full px-2 py-0.5 text-xs 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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<label className="text-xs 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-[15px]"
|
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 flex rounded-full bg-secondary p-1">
|
<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-[13px] font-bold uppercase",
|
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
|
||||||
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
|
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-[15px] font-bold uppercase"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" /> Chiudi set
|
<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-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" /> Fine partita
|
<Save className="h-4 w-4" /> Fine partita
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+56
-73
@@ -1,12 +1,11 @@
|
|||||||
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, ChevronRight, Crown, Trophy } from "lucide-react";
|
import { Cake, ChevronDown, 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,
|
||||||
@@ -51,7 +50,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-[13px] font-bold uppercase tracking-wide text-accent-foreground">
|
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-accent-foreground">
|
||||||
{ruolo}
|
{ruolo}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -147,7 +146,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-[15px] font-bold leading-tight">
|
<span className="block truncate text-sm 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">
|
||||||
@@ -164,7 +163,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-[13px] text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,12 +191,10 @@ 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-[15px] font-bold leading-tight">
|
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||||
{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-[13px] text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{g.numero ? `#${g.numero}` : "n° da definire"}
|
{g.numero ? `#${g.numero}` : "n° da definire"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -232,7 +229,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-[13px] text-muted-foreground">
|
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||||
{g.nascita.slice(0, 4)}
|
{g.nascita.slice(0, 4)}
|
||||||
</p>
|
</p>
|
||||||
@@ -246,11 +243,13 @@ 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 ? [] : [{ l: "Cacche 💩", v: g.cacchePartita || "—" }]),
|
...(sonoAllenatore
|
||||||
|
? []
|
||||||
|
: [{ 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-[13px] font-semibold uppercase text-muted-foreground">
|
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
{s.l}
|
{s.l}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,11 +258,11 @@ function Squadra() {
|
|||||||
|
|
||||||
{sonoAllenatore ? null : (
|
{sonoAllenatore ? null : (
|
||||||
<>
|
<>
|
||||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
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-[13px] text-muted-foreground">
|
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||||
Nessun badge sbloccato per ora.
|
Nessun badge sbloccato per ora.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -275,20 +274,17 @@ 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-[13px] font-bold leading-tight">
|
<p className="mt-1 text-xs font-bold leading-tight">
|
||||||
{b.def.nome}
|
{b.def.nome}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-xs 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(
|
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
|
||||||
"mt-1.5 text-[13px] font-bold uppercase",
|
|
||||||
meta.text,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</p>
|
</p>
|
||||||
@@ -330,8 +326,8 @@ function Squadra() {
|
|||||||
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
|
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-[15px] text-foreground/80">Classifica per</span>
|
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
|
||||||
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
|
<span className="min-w-0 flex-1 truncate text-right text-[14px] 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 />
|
||||||
@@ -349,7 +345,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-[15px] font-bold",
|
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm 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",
|
||||||
@@ -372,13 +368,13 @@ function Squadra() {
|
|||||||
{rank[i]}
|
{rank[i]}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-[15px] font-bold">
|
<p className="truncate text-sm 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-[13px] text-muted-foreground">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,14 +414,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-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||||
Progresso collettivo
|
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-[13px] text-primary-foreground/70">
|
<p className="text-xs text-primary-foreground/70">
|
||||||
{completati}/{obiettivi.length} completati
|
{completati}/{obiettivi.length} completati
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -436,47 +432,38 @@ function Squadra() {
|
|||||||
const pct = progressoObiettivo(o);
|
const pct = progressoObiettivo(o);
|
||||||
const fatto = pct >= 100;
|
const fatto = pct >= 100;
|
||||||
return (
|
return (
|
||||||
<Reveal key={o.id} indice={i}>
|
<Reveal
|
||||||
<ObiettivoDrawer obiettivo={o}>
|
key={o.id}
|
||||||
<button
|
indice={i}
|
||||||
type="button"
|
className={cn(
|
||||||
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
|
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
||||||
|
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="text-lg leading-none">{o.emoji}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"premi block w-full rounded-3xl bg-card p-4 text-left shadow-card ring-1",
|
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||||
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
fatto
|
||||||
|
? "bg-success text-success-foreground"
|
||||||
|
: "bg-secondary text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
{fatto ? "Completato" : `${pct}%`}
|
||||||
<span className="text-lg leading-none">{o.emoji}</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
</div>
|
||||||
<p className="text-[15px] font-bold leading-tight">{o.titolo}</p>
|
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||||
<p className="text-[13px] text-muted-foreground">{o.descrizione}</p>
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
</div>
|
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||||
<span
|
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||||
className={cn(
|
</p>
|
||||||
"rounded-full px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||||
fatto
|
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
||||||
? "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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -496,10 +483,8 @@ 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-[15px] font-bold leading-tight">{b.nome}</p>
|
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
||||||
<p className="ml-auto text-[13px] text-muted-foreground">
|
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
|
||||||
{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) => {
|
||||||
@@ -515,11 +500,9 @@ 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-[13px] font-bold uppercase", meta.text)}>
|
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
|
||||||
{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-[13px] text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{quanti}/{rosa.length}
|
{quanti}/{rosa.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
-- M31 — Notifica di compleanno (DD-047, issue #11)
|
|
||||||
--
|
|
||||||
-- Ogni giorno, dalle 8:00 (fuso Europe/Rome), chi ha il compleanno riceve il solo titolo di auguri e tutti gli
|
|
||||||
-- altri membri attivi della squadra (allenatori compresi) ricevono il solo avviso «Oggi è il compleanno
|
|
||||||
-- di ...», senza corpo e senza età.
|
|
||||||
-- Se in un giorno compiono gli anni più persone, ogni destinatario riceve un solo messaggio: chi non
|
|
||||||
-- festeggia con tutti i nomi, ogni festeggiato solo i propri auguri.
|
|
||||||
--
|
|
||||||
-- Canali come le altre notifiche automatiche (DD-038): la riga in `notifiche_utente` genera la mail
|
|
||||||
-- (il trigger di M26 lascia passare i nuovi tipi) e, con M31, anche la push dalla coda.
|
|
||||||
--
|
|
||||||
-- 29 febbraio: il confronto è tra giorno e mese, quindi chi è nato il 29 febbraio viene avvisato solo
|
|
||||||
-- negli anni bisestili. Limite noto e accettato (DD-047): non c'è un ripiego al 28 febbraio.
|
|
||||||
|
|
||||||
-- --- Tipi di notifica --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
|
|
||||||
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
|
|
||||||
tipo IN (
|
|
||||||
'admin',
|
|
||||||
'evento_promemoria_24h',
|
|
||||||
'evento_promemoria_3h',
|
|
||||||
'turno_palloni',
|
|
||||||
'turno_palloni_12h',
|
|
||||||
'turno_palloni_6h',
|
|
||||||
'turno_palloni_3h',
|
|
||||||
'turno_palloni_revocato',
|
|
||||||
'sollecita_presenze',
|
|
||||||
'sollecita_presenze_24h',
|
|
||||||
'sollecita_presenze_12h',
|
|
||||||
'sollecita_presenze_6h',
|
|
||||||
'sondaggio_cacche',
|
|
||||||
'compleanno',
|
|
||||||
'compleanno_auguri'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- --- Registro ----------------------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Una riga per destinatario e giorno: ricorda che l'avviso di quel giorno è già stato generato,
|
|
||||||
-- anche se il giocatore elimina la notifica (come `promemoria_eventi_generati`, DD-037). Non è legato
|
|
||||||
-- a un evento: i compleanni non sono `eventi_app`. Sparisce con il giocatore (DD-029).
|
|
||||||
|
|
||||||
CREATE TABLE public.compleanni_notificati (
|
|
||||||
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
|
||||||
giorno date NOT NULL,
|
|
||||||
generato_il timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (giocatore_id, giorno)
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON TABLE public.compleanni_notificati IS
|
|
||||||
'Avvisi di compleanno già generati per destinatario e giorno: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M31, DD-047).';
|
|
||||||
|
|
||||||
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
|
|
||||||
ALTER TABLE public.compleanni_notificati ENABLE ROW LEVEL SECURITY;
|
|
||||||
REVOKE ALL ON public.compleanni_notificati FROM PUBLIC, anon, authenticated;
|
|
||||||
GRANT ALL ON public.compleanni_notificati TO service_role;
|
|
||||||
|
|
||||||
-- --- Job ---------------------------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Gira ogni ora e agisce solo dalle 8:00 in poi, ora locale: un giro mancato si recupera all'ora
|
|
||||||
-- dopo e il registro impedisce di generare due volte lo stesso avviso. `pg_cron` ragiona in UTC e
|
|
||||||
-- l'ora legale sposta l'ora locale di una ora: per questo l'ora si legge qui, in `Europe/Rome`.
|
|
||||||
--
|
|
||||||
-- Il registro è per destinatario e giorno: chi entra in squadra o inserisce la data di nascita dopo
|
|
||||||
-- che gli avvisi del giorno sono partiti non genera un secondo giro (DD-047).
|
|
||||||
--
|
|
||||||
-- `p_adesso` esiste per i test, che simulano un'altra data; il job lo chiama senza argomenti.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.genera_avvisi_compleanno(p_adesso timestamptz DEFAULT now())
|
|
||||||
RETURNS void
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = public
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
adesso timestamp := p_adesso AT TIME ZONE 'Europe/Rome';
|
|
||||||
oggi date := (p_adesso AT TIME ZONE 'Europe/Rome')::date;
|
|
||||||
BEGIN
|
|
||||||
IF extract(hour FROM adesso) < 8 THEN
|
|
||||||
RETURN;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
WITH festeggiati AS (
|
|
||||||
SELECT g.id, g.nome
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.attivo = true
|
|
||||||
AND g.nascita IS NOT NULL
|
|
||||||
AND extract(month FROM g.nascita) = extract(month FROM oggi)
|
|
||||||
AND extract(day FROM g.nascita) = extract(day FROM oggi)
|
|
||||||
),
|
|
||||||
elenco AS (
|
|
||||||
SELECT array_agg(nome ORDER BY nome, id COLLATE "C") AS nomi
|
|
||||||
FROM festeggiati
|
|
||||||
),
|
|
||||||
nuovi AS (
|
|
||||||
INSERT INTO public.compleanni_notificati (giocatore_id, giorno)
|
|
||||||
SELECT g.id, oggi
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.attivo = true
|
|
||||||
AND EXISTS (SELECT 1 FROM festeggiati)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
RETURNING giocatore_id
|
|
||||||
)
|
|
||||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
|
|
||||||
SELECT n.giocatore_id, 'compleanno_auguri',
|
|
||||||
'Buon compleanno, ' || f.nome || '! 🎂',
|
|
||||||
''
|
|
||||||
FROM nuovi n
|
|
||||||
JOIN festeggiati f ON f.id = n.giocatore_id
|
|
||||||
UNION ALL
|
|
||||||
SELECT n.giocatore_id, 'compleanno',
|
|
||||||
'Oggi è il compleanno di '
|
|
||||||
|| CASE WHEN cardinality(e.nomi) = 1 THEN e.nomi[1]
|
|
||||||
ELSE array_to_string(e.nomi[1:cardinality(e.nomi) - 1], ', ')
|
|
||||||
|| ' e ' || e.nomi[cardinality(e.nomi)]
|
|
||||||
END,
|
|
||||||
''
|
|
||||||
FROM nuovi n
|
|
||||||
CROSS JOIN elenco e
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM festeggiati f WHERE f.id = n.giocatore_id);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
REVOKE ALL ON FUNCTION public.genera_avvisi_compleanno(timestamptz) FROM PUBLIC, anon, authenticated;
|
|
||||||
GRANT EXECUTE ON FUNCTION public.genera_avvisi_compleanno(timestamptz) TO service_role;
|
|
||||||
|
|
||||||
SELECT cron.schedule(
|
|
||||||
'avvisi-compleanno',
|
|
||||||
'0 * * * *',
|
|
||||||
$$ SELECT public.genera_avvisi_compleanno() $$
|
|
||||||
);
|
|
||||||
|
|
||||||
-- --- Push: anche i compleanni ------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Nascono nel database come le altre notifiche automatiche: nessuna route le manda, quindi la push
|
|
||||||
-- passa dalla coda (DD-038). Stesso trigger di M26 con i due tipi nuovi.
|
|
||||||
|
|
||||||
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
|
|
||||||
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
|
|
||||||
AFTER INSERT ON public.notifiche_utente
|
|
||||||
FOR EACH ROW
|
|
||||||
WHEN (NEW.tipo IN (
|
|
||||||
'evento_promemoria_24h',
|
|
||||||
'evento_promemoria_3h',
|
|
||||||
'turno_palloni_12h',
|
|
||||||
'turno_palloni_6h',
|
|
||||||
'turno_palloni_3h',
|
|
||||||
'turno_palloni_revocato',
|
|
||||||
'sollecita_presenze_24h',
|
|
||||||
'sollecita_presenze_12h',
|
|
||||||
'sollecita_presenze_6h',
|
|
||||||
'compleanno',
|
|
||||||
'compleanno_auguri'
|
|
||||||
))
|
|
||||||
EXECUTE FUNCTION public.accoda_push_promemoria();
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
-- M32 — Avviso se una presenza cambia a meno di 6 ore dall'evento (DD-050)
|
|
||||||
--
|
|
||||||
-- Quando una riga di `risposte_presenze` cambia (nuova, modificata o ritirata) e mancano da 0 a 6 ore
|
|
||||||
-- all'inizio di una partita o di un allenamento, ai destinatari arriva una notifica «Presenza modificata».
|
|
||||||
--
|
|
||||||
-- FASE DI PROVA: i destinatari sono solo gli admin (DD-050, «Fase di prova»). Per aprirla agli allenatori
|
|
||||||
-- basta aggiungere 'allenatore' alla lista dei ruoli qui sotto, con una nuova migration.
|
|
||||||
--
|
|
||||||
-- Dal database in poi il resto è già fatto: il trigger di M22/M26 accoda l'email, quello delle push
|
|
||||||
-- (esteso qui con il tipo nuovo) accoda la push, la riga è il centro notifiche in-app (DD-038).
|
|
||||||
|
|
||||||
-- --- Tipo 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',
|
|
||||||
'presenza_modificata'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- --- Funzione e trigger ------------------------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- `evento_id` resta NULL (come i messaggi `admin`): la UNIQUE (giocatore_id, evento_id, tipo) non si
|
|
||||||
-- applica e due modifiche dello stesso evento restano due notifiche distinte.
|
|
||||||
--
|
|
||||||
-- Cancellare un evento elimina le risposte con un trigger AFTER DELETE su `eventi_app` (M14): quando
|
|
||||||
-- scatta questo, l'evento non esiste più, quindi non parte nessun avviso.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
|
|
||||||
RETURNS trigger
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = public
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
|
|
||||||
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
|
|
||||||
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
|
|
||||||
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
|
|
||||||
ev record;
|
|
||||||
chi record;
|
|
||||||
BEGIN
|
|
||||||
IF prima IS NOT DISTINCT FROM dopo THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
|
|
||||||
INTO ev
|
|
||||||
FROM public.eventi_app e
|
|
||||||
WHERE e.id = evento_id_r;
|
|
||||||
|
|
||||||
IF NOT FOUND
|
|
||||||
OR ev.tipo NOT IN ('partita', 'allenamento')
|
|
||||||
OR ev.inizio IS NULL
|
|
||||||
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
|
|
||||||
INTO chi
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.id = giocatore_r;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
|
|
||||||
SELECT d.id,
|
|
||||||
'presenza_modificata',
|
|
||||||
'Presenza modificata: ' || chi.nome_completo,
|
|
||||||
'Evento: ' || ev.titolo
|
|
||||||
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|
|
||||||
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|
|
||||||
|| E'\nA: ' || public.nome_stato_presenza(dopo)
|
|
||||||
FROM public.giocatori_squadra d
|
|
||||||
WHERE d.attivo = true
|
|
||||||
AND d.id <> giocatore_r
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM public.user_roles ur
|
|
||||||
WHERE ur.user_id = d.auth_user_id
|
|
||||||
AND ur.role IN ('admin')
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
REVOKE ALL ON FUNCTION public.avvisa_presenza_modificata() FROM PUBLIC, anon, authenticated;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.nome_stato_presenza(p_stato text)
|
|
||||||
RETURNS text
|
|
||||||
LANGUAGE sql IMMUTABLE AS $$
|
|
||||||
SELECT CASE WHEN p_stato = 'ritardo' THEN 'in ritardo' ELSE coalesce(p_stato, 'nessuna risposta') END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
CREATE TRIGGER risposte_presenze_avvisa_modifica
|
|
||||||
AFTER INSERT OR UPDATE OF stato OR DELETE ON public.risposte_presenze
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION public.avvisa_presenza_modificata();
|
|
||||||
|
|
||||||
-- --- Push: anche la presenza modificata --------------------------------------------------------
|
|
||||||
--
|
|
||||||
-- Stesso trigger di M31 con il tipo nuovo. L'email passa già dal trigger di M22/M26.
|
|
||||||
|
|
||||||
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',
|
|
||||||
'presenza_modificata'
|
|
||||||
))
|
|
||||||
EXECUTE FUNCTION public.accoda_push_promemoria();
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
-- M33 — «Presenza modificata» anche agli allenatori (DD-050)
|
|
||||||
--
|
|
||||||
-- Fine della fase di prova della M32: l'avviso arriva agli admin e agli allenatori attivi (ruolo in
|
|
||||||
-- `user_roles` e slot attivo), tranne il giocatore a cui appartiene la presenza. Cambia solo la lista
|
|
||||||
-- dei ruoli nella funzione; trigger, tipo di notifica e code push/email restano quelli della M32.
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.avvisa_presenza_modificata()
|
|
||||||
RETURNS trigger
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = public
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
evento_id_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.evento_id ELSE NEW.evento_id END;
|
|
||||||
giocatore_r text := CASE WHEN TG_OP = 'DELETE' THEN OLD.giocatore_id ELSE NEW.giocatore_id END;
|
|
||||||
prima text := CASE WHEN TG_OP = 'INSERT' THEN NULL ELSE OLD.stato END;
|
|
||||||
dopo text := CASE WHEN TG_OP = 'DELETE' THEN NULL ELSE NEW.stato END;
|
|
||||||
ev record;
|
|
||||||
chi record;
|
|
||||||
BEGIN
|
|
||||||
IF prima IS NOT DISTINCT FROM dopo THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT e.titolo, e.data, e.ora, e.tipo, public.inizio_evento(e.data, e.ora) AS inizio
|
|
||||||
INTO ev
|
|
||||||
FROM public.eventi_app e
|
|
||||||
WHERE e.id = evento_id_r;
|
|
||||||
|
|
||||||
IF NOT FOUND
|
|
||||||
OR ev.tipo NOT IN ('partita', 'allenamento')
|
|
||||||
OR ev.inizio IS NULL
|
|
||||||
OR ev.inizio - now() NOT BETWEEN interval '0' AND interval '6 hours' THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
SELECT trim(g.nome || ' ' || g.cognome) AS nome_completo
|
|
||||||
INTO chi
|
|
||||||
FROM public.giocatori_squadra g
|
|
||||||
WHERE g.id = giocatore_r;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RETURN NULL;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
|
|
||||||
SELECT d.id,
|
|
||||||
'presenza_modificata',
|
|
||||||
'Presenza modificata: ' || chi.nome_completo,
|
|
||||||
'Evento: ' || ev.titolo
|
|
||||||
|| E'\nData: ' || to_char(ev.data, 'DD/MM/YYYY') || ', ore ' || ev.ora
|
|
||||||
|| E'\nDa: ' || public.nome_stato_presenza(prima)
|
|
||||||
|| E'\nA: ' || public.nome_stato_presenza(dopo)
|
|
||||||
FROM public.giocatori_squadra d
|
|
||||||
WHERE d.attivo = true
|
|
||||||
AND d.id <> giocatore_r
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM public.user_roles ur
|
|
||||||
WHERE ur.user_id = d.auth_user_id
|
|
||||||
AND ur.role IN ('admin', 'allenatore')
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN NULL;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
+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 (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 |
|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), l'avviso «Presenza modificata» ad admin e allenatori con finestra di 6 ore, stati e nessun avviso a cancellazione dell'evento (`presenza-modificata`, M32-M33, DD-050), 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ì |
|
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
||||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
| `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
|
||||||
|
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
/**
|
|
||||||
* Notifica di compleanno (M31, DD-047, issue #11): `bun test/integration/notifiche-compleanno.test.ts`.
|
|
||||||
*
|
|
||||||
* Esegue il job vero del database (`genera_avvisi_compleanno`) simulando date diverse con il
|
|
||||||
* parametro `p_adesso`, e verifica:
|
|
||||||
*
|
|
||||||
* - l'ora: prima delle 8:00 (fuso Europe/Rome, anche con l'ora legale) non parte nulla;
|
|
||||||
* - un compleanno: auguri al festeggiato, avviso con il solo nome a tutti gli altri membri attivi
|
|
||||||
* (allenatore compreso), niente a chi non è attivo;
|
|
||||||
* - più compleanni lo stesso giorno: un solo messaggio per destinatario, con i nomi in ordine
|
|
||||||
* alfabetico («A e B», «A, B e C»); ogni festeggiato riceve solo i propri auguri;
|
|
||||||
* - una sola generazione per destinatario e giorno, anche se la notifica viene eliminata;
|
|
||||||
* - il canale: push ed email in coda;
|
|
||||||
* - il 29 febbraio: avvisato solo negli anni bisestili (limite noto, DD-047);
|
|
||||||
* - che il job sia pianificato ogni ora.
|
|
||||||
*
|
|
||||||
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4`, `g5`, `g6`, `g9` (inattivo) e
|
|
||||||
* `g10` (allenatore) della rosa seed, ripristinati alla fine, e date simulate nel 2027/2028 in cui
|
|
||||||
* nessun altro slot compie gli anni (lo verifica il test stesso).
|
|
||||||
*/
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { statoLocale } from "../helpers/locale";
|
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
|
||||||
|
|
||||||
const locale = statoLocale();
|
|
||||||
|
|
||||||
if (!locale) {
|
|
||||||
salta("notifiche di compleanno", "stack locale non attivo (npx supabase start)");
|
|
||||||
riepilogo("notifiche-compleanno");
|
|
||||||
} else {
|
|
||||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
|
||||||
console.log(`notifiche di compleanno su ${URL_BASE}`);
|
|
||||||
|
|
||||||
const PASSWORD = "prova-notifiche-compleanno-123";
|
|
||||||
const G4 = "g4";
|
|
||||||
const G5 = "g5";
|
|
||||||
const G6 = "g6";
|
|
||||||
const INATTIVO = "g9";
|
|
||||||
const ALLENATORE = "g10";
|
|
||||||
const TIPI = "compleanno,compleanno_auguri";
|
|
||||||
|
|
||||||
const rest = (percorso: string, token: string, init?: RequestInit) =>
|
|
||||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
apikey: token === SERVIZIO ? SERVIZIO : ANON,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function leggi<T>(percorso: string): Promise<T[]> {
|
|
||||||
const res = await rest(percorso, SERVIZIO);
|
|
||||||
if (!res.ok) throw new Error(`select su ${percorso}: ${res.status} ${await res.text()}`);
|
|
||||||
return (await res.json()) as T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrivi(percorso: string, metodo: string, corpo?: unknown, token = SERVIZIO) {
|
|
||||||
const res = await rest(percorso, token, {
|
|
||||||
method: metodo,
|
|
||||||
body: corpo === undefined ? null : JSON.stringify(corpo),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`${metodo} su ${percorso}: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Il job, come se «adesso» fosse `quando` (istante UTC). */
|
|
||||||
async function gira(quando: string) {
|
|
||||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: SERVIZIO,
|
|
||||||
Authorization: `Bearer ${SERVIZIO}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ p_adesso: quando }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`genera_avvisi_compleanno: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function creaUtente(email: string): Promise<{ id: string; token: string }> {
|
|
||||||
const res = await fetch(`${URL_BASE}/auth/v1/admin/users`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: SERVIZIO,
|
|
||||||
Authorization: `Bearer ${SERVIZIO}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password: PASSWORD, email_confirm: true }),
|
|
||||||
});
|
|
||||||
const corpo = (await res.json()) as { id?: string };
|
|
||||||
if (!corpo.id) throw new Error(`creazione utente fallita: ${JSON.stringify(corpo)}`);
|
|
||||||
const login = await fetch(`${URL_BASE}/auth/v1/token?grant_type=password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: ANON, "content-type": "application/json" },
|
|
||||||
body: JSON.stringify({ email, password: PASSWORD }),
|
|
||||||
});
|
|
||||||
const t = (await login.json()) as { access_token?: string };
|
|
||||||
if (!t.access_token) throw new Error("accesso fallito");
|
|
||||||
return { id: corpo.id, token: t.access_token };
|
|
||||||
}
|
|
||||||
|
|
||||||
type Slot = { id: string; nome: string; nascita: string | null; attivo: boolean };
|
|
||||||
type Riga = { id: string; giocatore_id: string; tipo: string; titolo: string; corpo: string };
|
|
||||||
|
|
||||||
const slot = () => leggi<Slot>("giocatori_squadra?select=id,nome,nascita,attivo&order=id");
|
|
||||||
const notifiche = () =>
|
|
||||||
leggi<Riga>(
|
|
||||||
`notifiche_utente?tipo=in.(${TIPI})&select=id,giocatore_id,tipo,titolo,corpo&order=giocatore_id`,
|
|
||||||
);
|
|
||||||
const registro = (giorno: string) =>
|
|
||||||
leggi<{ giocatore_id: string }>(
|
|
||||||
`compleanni_notificati?giorno=eq.${giorno}&select=giocatore_id&order=giocatore_id`,
|
|
||||||
);
|
|
||||||
const impostaNascita = (id: string, nascita: string | null) =>
|
|
||||||
scrivi(`giocatori_squadra?id=eq.${id}`, "PATCH", { nascita }, tokenAdmin);
|
|
||||||
const svuota = async () => {
|
|
||||||
await rest(`notifiche_utente?tipo=in.(${TIPI})`, SERVIZIO, { method: "DELETE" });
|
|
||||||
await rest("compleanni_notificati?giorno=gte.2000-01-01", SERVIZIO, { method: "DELETE" });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Chi compie gli anni nel giorno `iso` secondo il database: attivo e con lo stesso giorno e mese. */
|
|
||||||
const festeggiatiDi = async (iso: string) =>
|
|
||||||
(await slot())
|
|
||||||
.filter((s) => s.attivo && s.nascita?.slice(5) === iso.slice(5))
|
|
||||||
.map((s) => s.id)
|
|
||||||
.sort();
|
|
||||||
const attivi = async () =>
|
|
||||||
(await slot())
|
|
||||||
.filter((s) => s.attivo)
|
|
||||||
.map((s) => s.id)
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
let tokenAdmin = "";
|
|
||||||
const idUtenti: string[] = [];
|
|
||||||
const originali = new Map<string, Record<string, unknown>>();
|
|
||||||
|
|
||||||
// Orari UTC corrispondenti a 07:30 e 08:30 a Roma: l'inverno è UTC+1, l'estate UTC+2.
|
|
||||||
const INVERNO = { prima: "T06:30:00Z", dopo: "T07:30:00Z" };
|
|
||||||
const ESTATE = { prima: "T05:30:00Z", dopo: "T06:30:00Z" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const admin = await creaUtente(`test-compleanno-admin-${Date.now()}@example.test`);
|
|
||||||
const allenatore = await creaUtente(`test-compleanno-allenatore-${Date.now()}@example.test`);
|
|
||||||
idUtenti.push(admin.id, allenatore.id);
|
|
||||||
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
|
|
||||||
tokenAdmin = admin.token;
|
|
||||||
for (const id of [G4, G5, G6, INATTIVO, ALLENATORE]) {
|
|
||||||
const [riga] = await leggi<Record<string, unknown>>(
|
|
||||||
`giocatori_squadra?id=eq.${id}&select=nascita,tipo,attivo,auth_user_id`,
|
|
||||||
);
|
|
||||||
originali.set(id, riga ?? { nascita: null, tipo: "giocatore", attivo: true });
|
|
||||||
}
|
|
||||||
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
|
|
||||||
await scrivi(
|
|
||||||
`giocatori_squadra?id=eq.${ALLENATORE}`,
|
|
||||||
"PATCH",
|
|
||||||
{ tipo: "allenatore", auth_user_id: allenatore.id },
|
|
||||||
tokenAdmin,
|
|
||||||
);
|
|
||||||
await scrivi(`giocatori_squadra?id=eq.${INATTIVO}`, "PATCH", { attivo: false }, tokenAdmin);
|
|
||||||
// Nessuno dei cinque slot di prova parte con una nascita che possa confondere i casi.
|
|
||||||
for (const id of [G4, G5, G6, INATTIVO]) await impostaNascita(id, null);
|
|
||||||
await svuota();
|
|
||||||
|
|
||||||
const tutti = await slot();
|
|
||||||
const nome = (id: string) => tutti.find((s) => s.id === id)!.nome;
|
|
||||||
const attiviIds = await attivi();
|
|
||||||
assert.ok(attiviIds.includes(ALLENATORE), "l'allenatore è attivo");
|
|
||||||
assert.ok(!attiviIds.includes(INATTIVO), "g9 è inattivo");
|
|
||||||
|
|
||||||
// --- Un compleanno ---------------------------------------------------------------------
|
|
||||||
// 15 marzo 2027, orario invernale: 07:30 a Roma è prima, 08:30 è dopo.
|
|
||||||
const GIORNO1 = "2027-03-15";
|
|
||||||
await impostaNascita(G4, "1995-03-15");
|
|
||||||
|
|
||||||
await prova("prima delle 8:00 a Roma non parte nulla, il registro resta vuoto", async () => {
|
|
||||||
assert.deepEqual(await festeggiatiDi(GIORNO1), [G4], "solo g4 compie gli anni quel giorno");
|
|
||||||
await gira(`${GIORNO1}${INVERNO.prima}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
assert.equal((await registro(GIORNO1)).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova(
|
|
||||||
"dalle 8:00: auguri al festeggiato, avviso con il solo nome a tutti gli altri",
|
|
||||||
async () => {
|
|
||||||
await gira(`${GIORNO1}${INVERNO.dopo}`);
|
|
||||||
const righe = await notifiche();
|
|
||||||
const mie = righe.filter((r) => r.giocatore_id === G4);
|
|
||||||
assert.equal(mie.length, 1);
|
|
||||||
assert.equal(mie[0]!.tipo, "compleanno_auguri");
|
|
||||||
assert.equal(mie[0]!.titolo, `Buon compleanno, ${nome(G4)}! 🎂`);
|
|
||||||
assert.equal(mie[0]!.corpo, "", "solo il titolo");
|
|
||||||
|
|
||||||
const altri = righe.filter((r) => r.giocatore_id !== G4);
|
|
||||||
assert.deepEqual(
|
|
||||||
altri.map((r) => r.giocatore_id).sort(),
|
|
||||||
attiviIds.filter((id) => id !== G4),
|
|
||||||
"tutti gli altri membri attivi, allenatore compreso, e nessun inattivo",
|
|
||||||
);
|
|
||||||
for (const r of altri) {
|
|
||||||
assert.equal(r.tipo, "compleanno");
|
|
||||||
assert.equal(r.titolo, `Oggi è il compleanno di ${nome(G4)}`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo, niente età");
|
|
||||||
}
|
|
||||||
assert.equal(righe.length, attiviIds.length, "una notifica a testa");
|
|
||||||
assert.deepEqual(
|
|
||||||
(await registro(GIORNO1)).map((r) => r.giocatore_id),
|
|
||||||
attiviIds,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("l'allenatore riceve l'avviso, l'inattivo niente", async () => {
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.filter((r) => r.giocatore_id === ALLENATORE).length, 1);
|
|
||||||
assert.equal(righe.filter((r) => r.giocatore_id === INATTIVO).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("push ed email vanno in coda per ogni notifica", async () => {
|
|
||||||
const ids = (await notifiche()).map((r) => r.id);
|
|
||||||
const mail = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_email_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
|
||||||
);
|
|
||||||
const push = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_push_coda?notifica_id=in.(${ids.join(",")})&select=notifica_id`,
|
|
||||||
);
|
|
||||||
assert.equal(mail.length, ids.length, "una mail per notifica");
|
|
||||||
assert.equal(push.length, ids.length, "una push per notifica");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un secondo giro lo stesso giorno non genera doppioni", async () => {
|
|
||||||
await gira(`${GIORNO1}T09:30:00Z`);
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("eliminare la notifica non la fa tornare", async () => {
|
|
||||||
const [prima] = await notifiche();
|
|
||||||
await scrivi(`notifiche_utente?id=eq.${prima!.id}`, "DELETE");
|
|
||||||
await gira(`${GIORNO1}T10:30:00Z`);
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length - 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un giorno senza compleanni non genera nulla, né registro", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-20";
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [], "nessuno compie gli anni il 20 marzo");
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
assert.equal((await registro(giorno)).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Ora legale ------------------------------------------------------------------------
|
|
||||||
await prova("con l'ora legale le 8:00 di Roma sono le 6:00 UTC", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-07-15";
|
|
||||||
await impostaNascita(G4, "1995-07-15");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4]);
|
|
||||||
await gira(`${giorno}${ESTATE.prima}`); // 07:30 a Roma
|
|
||||||
assert.equal((await notifiche()).length, 0, "alle 7:30 a Roma non parte ancora");
|
|
||||||
await gira(`${giorno}${ESTATE.dopo}`); // 08:30 a Roma
|
|
||||||
assert.equal((await notifiche()).length, attiviIds.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Più compleanni lo stesso giorno ---------------------------------------------------
|
|
||||||
await prova(
|
|
||||||
"due compleanni: un solo messaggio a testa, nomi in ordine alfabetico",
|
|
||||||
async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-16";
|
|
||||||
await impostaNascita(G4, "1995-03-16");
|
|
||||||
await impostaNascita(G5, "1990-03-16");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5]);
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.length, attiviIds.length, "un solo messaggio per destinatario");
|
|
||||||
const nomi = [nome(G4), nome(G5)].sort((a, b) => a.localeCompare(b, "it"));
|
|
||||||
for (const id of [G4, G5]) {
|
|
||||||
const r = righe.find((x) => x.giocatore_id === id)!;
|
|
||||||
assert.equal(r.tipo, "compleanno_auguri");
|
|
||||||
assert.equal(r.titolo, `Buon compleanno, ${nome(id)}! 🎂`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo");
|
|
||||||
assert.ok(!r.titolo.includes(nome(id === G4 ? G5 : G4)), "niente nomi degli altri");
|
|
||||||
}
|
|
||||||
const altri = righe.filter((r) => ![G4, G5].includes(r.giocatore_id));
|
|
||||||
assert.equal(altri.length, attiviIds.length - 2);
|
|
||||||
for (const r of altri) {
|
|
||||||
assert.equal(r.tipo, "compleanno");
|
|
||||||
assert.equal(r.titolo, `Oggi è il compleanno di ${nomi[0]} e ${nomi[1]}`);
|
|
||||||
assert.equal(r.corpo, "", "solo il titolo");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("tre compleanni: «A, B e C»", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-17";
|
|
||||||
await impostaNascita(G4, "1995-03-17");
|
|
||||||
await impostaNascita(G5, "1990-03-17");
|
|
||||||
await impostaNascita(G6, "2000-03-17");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [G4, G5, G6]);
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
const nomi = [nome(G4), nome(G5), nome(G6)].sort((a, b) => a.localeCompare(b, "it"));
|
|
||||||
const altro = (await notifiche()).find((r) => ![G4, G5, G6].includes(r.giocatore_id))!;
|
|
||||||
assert.equal(altro.titolo, `Oggi è il compleanno di ${nomi[0]}, ${nomi[1]} e ${nomi[2]}`);
|
|
||||||
assert.equal(altro.corpo, "", "solo il titolo");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("un festeggiato inattivo non conta: né avviso né auguri", async () => {
|
|
||||||
await svuota();
|
|
||||||
const giorno = "2027-03-18";
|
|
||||||
for (const id of [G4, G5, G6]) await impostaNascita(id, null);
|
|
||||||
await impostaNascita(INATTIVO, "2003-03-18");
|
|
||||||
assert.deepEqual(await festeggiatiDi(giorno), [], "g9 è inattivo");
|
|
||||||
await gira(`${giorno}${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 29 febbraio: limite noto (DD-047) -------------------------------------------------
|
|
||||||
await prova("chi è nato il 29 febbraio è avvisato solo negli anni bisestili", async () => {
|
|
||||||
await svuota();
|
|
||||||
await impostaNascita(INATTIVO, null);
|
|
||||||
await impostaNascita(G4, "2000-02-29");
|
|
||||||
// 2027 non è bisestile: né il 28 febbraio né il 1° marzo.
|
|
||||||
await gira(`2027-02-28${INVERNO.dopo}`);
|
|
||||||
await gira(`2027-03-01${INVERNO.dopo}`);
|
|
||||||
assert.equal((await notifiche()).length, 0, "negli anni normali non parte nulla");
|
|
||||||
// 2028 è bisestile: il 29 febbraio sì.
|
|
||||||
assert.deepEqual(await festeggiatiDi("2028-02-29"), [G4]);
|
|
||||||
await gira(`2028-02-29${INVERNO.dopo}`);
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.length, attiviIds.length);
|
|
||||||
assert.equal(righe.find((r) => r.giocatore_id === G4)!.tipo, "compleanno_auguri");
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Pianificazione --------------------------------------------------------------------
|
|
||||||
await prova("il job è pianificato ogni ora", async () => {
|
|
||||||
const contenitore = spawnSync(
|
|
||||||
"docker",
|
|
||||||
["ps", "--filter", "name=supabase_db", "--format", "{{.Names}}"],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
)
|
|
||||||
.stdout.trim()
|
|
||||||
.split("\n")[0];
|
|
||||||
assert.ok(contenitore, "container del database locale non trovato");
|
|
||||||
const esito = spawnSync(
|
|
||||||
"docker",
|
|
||||||
[
|
|
||||||
"exec",
|
|
||||||
contenitore,
|
|
||||||
"psql",
|
|
||||||
"-U",
|
|
||||||
"postgres",
|
|
||||||
"-At",
|
|
||||||
"-c",
|
|
||||||
"select schedule from cron.job where jobname = 'avvisi-compleanno'",
|
|
||||||
],
|
|
||||||
{ encoding: "utf8" },
|
|
||||||
);
|
|
||||||
assert.equal(esito.status, 0, esito.stderr);
|
|
||||||
assert.equal(esito.stdout.trim(), "0 * * * *");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("il job non è chiamabile dai client", async () => {
|
|
||||||
for (const token of [ANON, admin.token]) {
|
|
||||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/genera_avvisi_compleanno`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: ANON,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterlo eseguire`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await svuota();
|
|
||||||
if (tokenAdmin) {
|
|
||||||
for (const [id, valori] of originali) {
|
|
||||||
await rest(`giocatori_squadra?id=eq.${id}`, tokenAdmin, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(valori),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const id of idUtenti) {
|
|
||||||
await rest(`user_roles?user_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
|
||||||
await fetch(`${URL_BASE}/auth/v1/admin/users/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { apikey: SERVIZIO, Authorization: `Bearer ${SERVIZIO}` },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
riepilogo("notifiche-compleanno");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle
|
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
|
||||||
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
* 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 { serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
import { contaPresenzeGiocatore, 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,51 +282,33 @@ 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 pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
|
const inserita = await rest("eventi_app", {
|
||||||
// OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
|
method: "POST",
|
||||||
for (const [id, giorno] of [
|
body: JSON.stringify({
|
||||||
[pizzataId, "10"],
|
id: pizzataId,
|
||||||
[pizzataFuturaId, "20"],
|
tipo: "evento",
|
||||||
] as const) {
|
titolo: "Test obiettivi o6",
|
||||||
const inserita = await rest("eventi_app", {
|
data: `${MESE_TEST}-20`,
|
||||||
method: "POST",
|
}),
|
||||||
body: JSON.stringify({
|
});
|
||||||
id,
|
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
||||||
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 tutti = await leggiEventi();
|
const eventiReali = await leggiEventi();
|
||||||
const o6Di = (ids: string[], oggi: Date) =>
|
const o6InMese = obiettiviSquadra(
|
||||||
obiettiviSquadra(
|
giocatori,
|
||||||
giocatori,
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
{ eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
|
OGGI,
|
||||||
oggi,
|
).find((o) => o.id === "o6")!;
|
||||||
).find((o) => o.id === "o6")!;
|
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
|
||||||
|
|
||||||
|
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
|
||||||
|
const o6MeseDopo = obiettiviSquadra(
|
||||||
|
giocatori,
|
||||||
|
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||||
|
OGGI_MESE_DOPO,
|
||||||
|
).find((o) => o.id === "o6")!;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
o6Di([pizzataId], OGGI).valore,
|
o6MeseDopo.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)",
|
||||||
);
|
);
|
||||||
@@ -404,129 +386,68 @@ if (!locale) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await prova(
|
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
|
||||||
"o7 percentuale di presenze collettive da eventi/risposte reali del database",
|
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
|
||||||
async () => {
|
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
|
||||||
// o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
|
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
|
||||||
// convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
|
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
|
||||||
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||||
const partitaId = `${PREFISSO}-o7-partita`;
|
const partitaId = `${PREFISSO}-o7-partita`;
|
||||||
const [g1, g2, g3] = giocatori;
|
const OGGI_STR = "2099-01-20";
|
||||||
|
|
||||||
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(righe),
|
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
|
||||||
});
|
});
|
||||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
const eventiReali = (await leggiEventi()).filter(
|
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
|
||||||
(e) => e.id === allenamentoId || e.id === partitaId,
|
// il ritardo conta). g3: assente a entrambi (0 presenze).
|
||||||
);
|
const [g1, g2, g3] = giocatori;
|
||||||
const presenzeReali = {
|
const righe = [
|
||||||
...(await leggiPresenze(allenamentoId)),
|
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
...(await leggiPresenze(partitaId)),
|
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
||||||
};
|
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
||||||
|
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
||||||
|
];
|
||||||
|
const inserite = await rest("risposte_presenze", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(righe),
|
||||||
|
});
|
||||||
|
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||||
|
|
||||||
const o7 = obiettiviSquadra([g1!, g2!, g3!], {
|
const eventiReali = (await leggiEventi()).filter(
|
||||||
eventi: eventiReali,
|
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||||
presenze: presenzeReali,
|
);
|
||||||
pagelle: [],
|
const presenzeReali = {
|
||||||
}).find((o) => o.id === "o7")!;
|
...(await leggiPresenze(allenamentoId)),
|
||||||
|
...(await leggiPresenze(partitaId)),
|
||||||
|
};
|
||||||
|
|
||||||
assert.equal(
|
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
|
||||||
o7.valore,
|
...g,
|
||||||
67,
|
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
|
||||||
"4 presenze su 6 posti (il ritardo conta), calcolate dal database",
|
}));
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova(
|
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
|
||||||
"o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no",
|
eventi: [],
|
||||||
async () => {
|
presenze: {},
|
||||||
// Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa:
|
pagelle: [],
|
||||||
// g1 presente, g2 assente (presenza persa), g3 in ritardo. g4 non è convocato ma risponde
|
}).find((o) => o.id === "o7")!;
|
||||||
// «assente»: non deve pesare. La cena del mese ha risposte «assente» e non deve contare.
|
|
||||||
const allenamentoId = `${PREFISSO}-o1-convocati-allenamento`;
|
|
||||||
const cenaId = `${PREFISSO}-o1-convocati-cena`;
|
|
||||||
const [g1, g2, g3, g4] = giocatori;
|
|
||||||
for (const [id, tipo, data, convocati] of [
|
|
||||||
[allenamentoId, "allenamento", "2099-03-12", [g1!.id, g2!.id, g3!.id]],
|
|
||||||
[cenaId, "evento", "2099-03-14", [g1!.id, g2!.id, g3!.id]],
|
|
||||||
] as const) {
|
|
||||||
const inserito = await rest("eventi_app", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
id,
|
|
||||||
tipo,
|
|
||||||
titolo: `Test o1 convocati (${tipo})`,
|
|
||||||
data,
|
|
||||||
convocati,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
|
||||||
}
|
|
||||||
const righe = [
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "assente" },
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "ritardo" },
|
|
||||||
{ evento_id: allenamentoId, giocatore_id: g4!.id, stato: "assente" },
|
|
||||||
{ evento_id: cenaId, giocatore_id: g1!.id, stato: "assente" },
|
|
||||||
{ evento_id: cenaId, giocatore_id: g2!.id, stato: "assente" },
|
|
||||||
];
|
|
||||||
const inserite = await rest("risposte_presenze", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(righe),
|
|
||||||
});
|
|
||||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
|
||||||
|
|
||||||
const ids = [allenamentoId, cenaId];
|
assert.equal(
|
||||||
const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id));
|
o7.valore,
|
||||||
const presenzeReali = {
|
4,
|
||||||
...(await leggiPresenze(allenamentoId)),
|
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
|
||||||
...(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`;
|
||||||
|
|||||||
@@ -1,440 +0,0 @@
|
|||||||
/**
|
|
||||||
* Avviso «Presenza modificata» (M32 e M33, DD-050): `bun test/integration/presenza-modificata.test.ts`.
|
|
||||||
*
|
|
||||||
* Scrive su `risposte_presenze` come farebbe l'app e verifica, sul database vero:
|
|
||||||
*
|
|
||||||
* - una risposta nuova, modificata o ritirata a meno di 6 ore da una partita o da un allenamento
|
|
||||||
* avvisa gli admin e gli allenatori attivi, con titolo e corpo del catalogo;
|
|
||||||
* - riceve l'avviso chi ha il ruolo admin o allenatore con uno slot attivo (M33); non riceve nulla un giocatore semplice, e non riceve
|
|
||||||
* nulla l'admin a cui appartiene la presenza;
|
|
||||||
* - nessun avviso se lo stato non cambia, se l'evento è oltre le 6 ore, già iniziato, o di un tipo che
|
|
||||||
* non è partita né allenamento, né quando una risposta sparisce perché l'evento è stato cancellato;
|
|
||||||
* - ogni modifica è una notifica a sé (nessun raggruppamento) e ha la coda email e la coda push.
|
|
||||||
*
|
|
||||||
* Gira solo sullo stack locale (`npx supabase start`). Usa gli slot `g4` (chi risponde), `g5` (admin),
|
|
||||||
* `g6` (giocatore), `g9` (allenatore inattivo) e `g10` (allenatore) della rosa seed, ripristinati alla fine.
|
|
||||||
*/
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { statoLocale } from "../helpers/locale";
|
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
|
||||||
|
|
||||||
const locale = statoLocale();
|
|
||||||
|
|
||||||
if (!locale) {
|
|
||||||
salta("presenza modificata", "stack locale non attivo (npx supabase start)");
|
|
||||||
riepilogo("presenza-modificata");
|
|
||||||
} else {
|
|
||||||
const { url: URL_BASE, anon: ANON, servizio: SERVIZIO } = locale;
|
|
||||||
console.log(`presenza modificata su ${URL_BASE}`);
|
|
||||||
|
|
||||||
const PASSWORD = "prova-presenza-modificata-123";
|
|
||||||
const RISPONDE = "g4";
|
|
||||||
const ADMIN = "g5";
|
|
||||||
const GIOCATORE = "g6";
|
|
||||||
const ALLENATORE = "g10";
|
|
||||||
const INATTIVO = "g9";
|
|
||||||
const PREFISSO = "test-presenza-mod-";
|
|
||||||
|
|
||||||
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()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Data (AAAA-MM-GG) e ora (HH:MM) a Roma, tra `ore` ore da adesso. */
|
|
||||||
function traOre(ore: number): { data: string; ora: string } {
|
|
||||||
const parti: Record<string, string> = Object.fromEntries(
|
|
||||||
new Intl.DateTimeFormat("en-GB", {
|
|
||||||
timeZone: "Europe/Rome",
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
})
|
|
||||||
.formatToParts(new Date(Date.now() + ore * 3_600_000))
|
|
||||||
.map((p) => [p.type, p.value]),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
data: `${parti["year"]}-${parti["month"]}-${parti["day"]}`,
|
|
||||||
ora: `${parti["hour"]}:${parti["minute"]}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type Riga = { id: string; giocatore_id: string; titolo: string; corpo: string };
|
|
||||||
|
|
||||||
const notifiche = () =>
|
|
||||||
leggi<Riga>(
|
|
||||||
"notifiche_utente?tipo=eq.presenza_modificata&select=id,giocatore_id,titolo,corpo&order=creato_il",
|
|
||||||
);
|
|
||||||
const svuota = async () => {
|
|
||||||
await rest("notifiche_utente?tipo=eq.presenza_modificata", SERVIZIO, { method: "DELETE" });
|
|
||||||
};
|
|
||||||
const risposta = (evento: string, stato: string) =>
|
|
||||||
scrivi("risposte_presenze?on_conflict=evento_id,giocatore_id", "POST", {
|
|
||||||
evento_id: evento,
|
|
||||||
giocatore_id: RISPONDE,
|
|
||||||
stato,
|
|
||||||
});
|
|
||||||
const aggiorna = (evento: string, stato: string, giocatore = RISPONDE) =>
|
|
||||||
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${giocatore}`, "PATCH", {
|
|
||||||
stato,
|
|
||||||
});
|
|
||||||
const ritira = (evento: string) =>
|
|
||||||
scrivi(`risposte_presenze?evento_id=eq.${evento}&giocatore_id=eq.${RISPONDE}`, "DELETE");
|
|
||||||
async function creaEvento(id: string, tipo: string, ore: number, titolo = `Prova ${id}`) {
|
|
||||||
const { data, ora } = traOre(ore);
|
|
||||||
await scrivi("eventi_app", "POST", { id, tipo, titolo, data, ora });
|
|
||||||
return { data, ora, titolo };
|
|
||||||
}
|
|
||||||
|
|
||||||
let tokenAdmin = "";
|
|
||||||
const idUtenti: string[] = [];
|
|
||||||
const idEventi: string[] = [];
|
|
||||||
const originali = new Map<string, Record<string, unknown>>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const admin = await creaUtente(`${PREFISSO}admin-${Date.now()}@example.test`);
|
|
||||||
const allenatore = await creaUtente(`${PREFISSO}allenatore-${Date.now()}@example.test`);
|
|
||||||
const inattivo = await creaUtente(`${PREFISSO}inattivo-${Date.now()}@example.test`);
|
|
||||||
const senzaSlot = await creaUtente(`${PREFISSO}senza-slot-${Date.now()}@example.test`);
|
|
||||||
idUtenti.push(admin.id, allenatore.id, inattivo.id, senzaSlot.id);
|
|
||||||
await scrivi("user_roles", "POST", { user_id: admin.id, role: "admin" });
|
|
||||||
tokenAdmin = admin.token;
|
|
||||||
for (const id of [RISPONDE, ADMIN, GIOCATORE, ALLENATORE, INATTIVO]) {
|
|
||||||
const [riga] = await leggi<Record<string, unknown>>(
|
|
||||||
`giocatori_squadra?id=eq.${id}&select=tipo,attivo,auth_user_id`,
|
|
||||||
);
|
|
||||||
originali.set(id, riga ?? { tipo: "giocatore", attivo: true, auth_user_id: null });
|
|
||||||
}
|
|
||||||
// Il trigger di `giocatori_squadra` rifiuta la service key: serve il JWT dell'admin.
|
|
||||||
await scrivi(
|
|
||||||
`giocatori_squadra?id=eq.${ADMIN}`,
|
|
||||||
"PATCH",
|
|
||||||
{ auth_user_id: admin.id },
|
|
||||||
tokenAdmin,
|
|
||||||
);
|
|
||||||
await scrivi(
|
|
||||||
`giocatori_squadra?id=eq.${ALLENATORE}`,
|
|
||||||
"PATCH",
|
|
||||||
{ tipo: "allenatore", auth_user_id: allenatore.id },
|
|
||||||
tokenAdmin,
|
|
||||||
);
|
|
||||||
// Un allenatore con slot disattivato, un admin senza nessuno slot e un admin che ha anche il
|
|
||||||
// ruolo allenatore: nessuno dei primi due può ricevere, il terzo riceve una sola notifica.
|
|
||||||
await scrivi(
|
|
||||||
`giocatori_squadra?id=eq.${INATTIVO}`,
|
|
||||||
"PATCH",
|
|
||||||
{ tipo: "allenatore", auth_user_id: inattivo.id, attivo: false },
|
|
||||||
tokenAdmin,
|
|
||||||
);
|
|
||||||
await scrivi("user_roles", "POST", { user_id: senzaSlot.id, role: "admin" });
|
|
||||||
await scrivi("user_roles", "POST", { user_id: admin.id, role: "allenatore" });
|
|
||||||
await svuota();
|
|
||||||
|
|
||||||
const [chi] = await leggi<{ nome: string; cognome: string }>(
|
|
||||||
`giocatori_squadra?id=eq.${RISPONDE}&select=nome,cognome`,
|
|
||||||
);
|
|
||||||
const nomeCompleto = `${chi!.nome} ${chi!.cognome}`.trim();
|
|
||||||
|
|
||||||
// Il seed può avere altri admin con uno slot attivo: l'elenco atteso lo calcola il test.
|
|
||||||
const attesi = async () => {
|
|
||||||
const ruoli = await leggi<{ user_id: string }>(
|
|
||||||
"user_roles?role=in.(admin,allenatore)&select=user_id",
|
|
||||||
);
|
|
||||||
const slot = await leggi<{ id: string; auth_user_id: string }>(
|
|
||||||
"giocatori_squadra?attivo=eq.true&auth_user_id=not.is.null&select=id,auth_user_id",
|
|
||||||
);
|
|
||||||
const idAdmin = new Set(ruoli.map((r) => r.user_id));
|
|
||||||
return slot
|
|
||||||
.filter((s) => idAdmin.has(s.auth_user_id) && s.id !== RISPONDE)
|
|
||||||
.map((s) => s.id)
|
|
||||||
.sort();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Partita a 3 ore ---------------------------------------------------------------------
|
|
||||||
const ev = await creaEvento(`${PREFISSO}partita`, "partita", 3, "Partita di prova");
|
|
||||||
idEventi.push(`${PREFISSO}partita`);
|
|
||||||
const dataIt = `${ev.data.slice(8, 10)}/${ev.data.slice(5, 7)}/${ev.data.slice(0, 4)}`;
|
|
||||||
|
|
||||||
await prova(
|
|
||||||
"risposta nuova: avvisa admin e allenatori, con il testo del catalogo",
|
|
||||||
async () => {
|
|
||||||
await risposta(`${PREFISSO}partita`, "presente");
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.deepEqual(righe.map((r) => r.giocatore_id).sort(), await attesi());
|
|
||||||
assert.ok(
|
|
||||||
righe.some((r) => r.giocatore_id === ADMIN),
|
|
||||||
"l'admin di prova è tra i destinatari",
|
|
||||||
);
|
|
||||||
const mia = righe.find((r) => r.giocatore_id === ADMIN)!;
|
|
||||||
assert.equal(mia.titolo, `Presenza modificata: ${nomeCompleto}`);
|
|
||||||
assert.equal(
|
|
||||||
mia.corpo,
|
|
||||||
[
|
|
||||||
`Evento: ${ev.titolo}`,
|
|
||||||
`Data: ${dataIt}, ore ${ev.ora}`,
|
|
||||||
"Da: nessuna risposta",
|
|
||||||
"A: presente",
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("il giocatore semplice non riceve nulla, l'allenatore sì", async () => {
|
|
||||||
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
|
|
||||||
assert.ok(!destinatari.includes(GIOCATORE));
|
|
||||||
assert.ok(destinatari.includes(ALLENATORE), "l'allenatore attivo è tra i destinatari");
|
|
||||||
assert.ok(!destinatari.includes(RISPONDE), "chi risponde non avvisa se stesso");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("la riga ha la coda email e la coda push", async () => {
|
|
||||||
const [mia] = (await notifiche()).filter((r) => r.giocatore_id === ADMIN);
|
|
||||||
const email = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_email_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
|
|
||||||
);
|
|
||||||
assert.equal(email.length, 1, "email in coda");
|
|
||||||
const push = await leggi<{ notifica_id: string }>(
|
|
||||||
`notifiche_push_coda?notifica_id=eq.${mia!.id}&select=notifica_id`,
|
|
||||||
);
|
|
||||||
assert.equal(push.length, 1, "push in coda");
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("modifica: ogni cambio è una notifica a sé, con Da e A giusti", async () => {
|
|
||||||
const prima = (await notifiche()).length;
|
|
||||||
await aggiorna(`${PREFISSO}partita`, "ritardo");
|
|
||||||
const righe = await notifiche();
|
|
||||||
assert.equal(righe.length, prima + (await attesi()).length);
|
|
||||||
const ultima = righe.filter((r) => r.giocatore_id === ADMIN).at(-1)!;
|
|
||||||
assert.match(ultima.corpo, /\nDa: presente\nA: in ritardo$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("stesso stato di prima: nessun avviso", async () => {
|
|
||||||
const prima = (await notifiche()).length;
|
|
||||||
await aggiorna(`${PREFISSO}partita`, "ritardo");
|
|
||||||
assert.equal((await notifiche()).length, prima);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("ritiro della risposta: «nessuna risposta» come stato finale", async () => {
|
|
||||||
await ritira(`${PREFISSO}partita`);
|
|
||||||
const ultima = (await notifiche()).filter((r) => r.giocatore_id === ADMIN).at(-1)!;
|
|
||||||
assert.match(ultima.corpo, /\nDa: in ritardo\nA: nessuna risposta$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("la presenza dell'admin non avvisa l'admin stesso", async () => {
|
|
||||||
await svuota();
|
|
||||||
await scrivi("risposte_presenze", "POST", {
|
|
||||||
evento_id: `${PREFISSO}partita`,
|
|
||||||
giocatore_id: ADMIN,
|
|
||||||
stato: "assente",
|
|
||||||
});
|
|
||||||
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
|
|
||||||
assert.ok(!destinatari.includes(ADMIN));
|
|
||||||
await scrivi(
|
|
||||||
`risposte_presenze?evento_id=eq.${PREFISSO}partita&giocatore_id=eq.${ADMIN}`,
|
|
||||||
"DELETE",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Casi in cui non deve partire nulla --------------------------------------------------
|
|
||||||
await prova("allenamento a 5 ore: avvisa", async () => {
|
|
||||||
await svuota();
|
|
||||||
await creaEvento(`${PREFISSO}allenamento`, "allenamento", 5);
|
|
||||||
idEventi.push(`${PREFISSO}allenamento`);
|
|
||||||
await risposta(`${PREFISSO}allenamento`, "assente");
|
|
||||||
assert.equal((await notifiche()).length, (await attesi()).length);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [nome, tipo, ore] of [
|
|
||||||
["oltre le 6 ore", "partita", 7],
|
|
||||||
["già iniziato", "partita", -1],
|
|
||||||
["un tipo che non è partita né allenamento", "evento", 3],
|
|
||||||
] as const) {
|
|
||||||
await prova(`nessun avviso: ${nome}`, async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}no-${idEventi.length}`;
|
|
||||||
await creaEvento(id, tipo, ore);
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prova("cancellare l'evento elimina le risposte senza avvisare nessuno", async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}cancellato`;
|
|
||||||
await creaEvento(id, "partita", 2);
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
await svuota();
|
|
||||||
await scrivi(`eventi_app?id=eq.${id}`, "DELETE");
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova(
|
|
||||||
"destinatari: né inattivi né senza slot, e una sola notifica a chi ha due ruoli",
|
|
||||||
async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}destinatari`;
|
|
||||||
await creaEvento(id, "partita", 4);
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
const destinatari = (await notifiche()).map((r) => r.giocatore_id);
|
|
||||||
assert.ok(!destinatari.includes(INATTIVO), "slot disattivato");
|
|
||||||
assert.deepEqual([...destinatari].sort(), await attesi());
|
|
||||||
assert.equal(
|
|
||||||
destinatari.filter((d) => d === ADMIN).length,
|
|
||||||
1,
|
|
||||||
"chi ha sia il ruolo admin sia allenatore riceve una sola notifica",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova(
|
|
||||||
"cambio di un giocatore: avvisati admin e allenatore, mai il giocatore stesso",
|
|
||||||
async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}per-conto`;
|
|
||||||
await creaEvento(id, "partita", 2);
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
await aggiorna(id, "assente");
|
|
||||||
const destinatari = new Set((await notifiche()).map((r) => r.giocatore_id));
|
|
||||||
assert.ok(destinatari.has(ALLENATORE) && destinatari.has(ADMIN));
|
|
||||||
assert.ok(!destinatari.has(RISPONDE));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await prova("tutti gli stati hanno il loro nome nel testo", async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}stati`;
|
|
||||||
await creaEvento(id, "allenamento", 1);
|
|
||||||
idEventi.push(id);
|
|
||||||
const sequenza = ["presente", "assente", "forse", "ritardo", "infortunato"];
|
|
||||||
await risposta(id, sequenza[0]!);
|
|
||||||
for (const stato of sequenza.slice(1)) await aggiorna(id, stato);
|
|
||||||
const corpi = (await notifiche())
|
|
||||||
.filter((r) => r.giocatore_id === ADMIN)
|
|
||||||
.map((r) => r.corpo.split("\n").slice(2).join("|"));
|
|
||||||
assert.deepEqual(corpi, [
|
|
||||||
"Da: nessuna risposta|A: presente",
|
|
||||||
"Da: presente|A: assente",
|
|
||||||
"Da: assente|A: forse",
|
|
||||||
"Da: forse|A: in ritardo",
|
|
||||||
"Da: in ritardo|A: infortunato",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Confini della finestra: la soglia è di 6 ore esatte (da 0 a 6).
|
|
||||||
for (const [nome, ore, avvisa] of [
|
|
||||||
["a 5 ore e 50 minuti", 5 + 50 / 60, true],
|
|
||||||
["a 6 ore e 10 minuti", 6 + 10 / 60, false],
|
|
||||||
["a 10 minuti", 10 / 60, true],
|
|
||||||
["iniziato da 10 minuti", -10 / 60, false],
|
|
||||||
] as const) {
|
|
||||||
await prova(`finestra: evento ${nome} ${avvisa ? "avvisa" : "non avvisa"}`, async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}soglia-${idEventi.length}`;
|
|
||||||
await creaEvento(id, "partita", ore);
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
assert.equal((await notifiche()).length > 0, avvisa);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await prova("ora dell'evento illeggibile: nessun avviso e nessun errore", async () => {
|
|
||||||
await svuota();
|
|
||||||
const id = `${PREFISSO}ora-illeggibile`;
|
|
||||||
const { data } = traOre(3);
|
|
||||||
await scrivi("eventi_app", "POST", {
|
|
||||||
id,
|
|
||||||
tipo: "partita",
|
|
||||||
titolo: "Ora strana",
|
|
||||||
data,
|
|
||||||
ora: "boh",
|
|
||||||
});
|
|
||||||
idEventi.push(id);
|
|
||||||
await risposta(id, "presente");
|
|
||||||
assert.equal((await notifiche()).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await prova("la funzione non è chiamabile dai client", async () => {
|
|
||||||
for (const token of [ANON, admin.token]) {
|
|
||||||
const res = await fetch(`${URL_BASE}/rest/v1/rpc/avvisa_presenza_modificata`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
apikey: ANON,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
assert.ok(!res.ok, `risposta ${res.status}: un client non deve poterla eseguire`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await svuota();
|
|
||||||
for (const id of idEventi) {
|
|
||||||
await rest(`risposte_presenze?evento_id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
|
||||||
await rest(`eventi_app?id=eq.${id}`, SERVIZIO, { method: "DELETE" });
|
|
||||||
}
|
|
||||||
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("presenza-modificata");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Badge segreto Mai un forfait end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/s-mai-forfait-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `serieConferme()`, `contaPresenzeGiocatore()` e `statoBadge()` come funzioni pure. Qui invece
|
||||||
|
* si scrivono eventi con `creato_il` esplicito e risposte con `risposto_il` esplicito su
|
||||||
|
* `eventi_app`/`risposte_presenze`, si rileggono via REST e si passa il risultato attraverso
|
||||||
|
* entrambe le funzioni fino a `statoBadge()` sul badge segreto `s-mai-forfait`, che è l'unico a
|
||||||
|
* combinare due statistiche indipendenti (serie di conferme rapide **e** presenze totali).
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-s-mai-forfait-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeSegreti, statoBadge } from "@/lib/badges";
|
||||||
|
import { serieConferme, contaPresenzeGiocatore } from "@/lib/presenze";
|
||||||
|
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||||
|
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
import type { Stato } from "@/lib/crapp-data";
|
||||||
|
import type { MappaPresenze } from "@/lib/presenze";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("badge Mai un forfait sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("s-mai-forfait-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Mai un forfait su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-s-mai-forfait-badge";
|
||||||
|
const def = badgeSegreti.find((b) => b.id === "s-mai-forfait")!;
|
||||||
|
const OGGI = "2099-01-01";
|
||||||
|
|
||||||
|
const rest = (percorso: string, init?: RequestInit) =>
|
||||||
|
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataEvento = (i: number) => {
|
||||||
|
const d = new Date(Date.UTC(2020, 0, 1));
|
||||||
|
d.setUTCDate(d.getUTCDate() + i - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function creaEvento(id: string, i: number) {
|
||||||
|
const res = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
tipo: "allenamento",
|
||||||
|
titolo: `Evento ${id}`,
|
||||||
|
data: dataEvento(i),
|
||||||
|
creato_il: `${dataEvento(i)}T08:00:00Z`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(eventoId: string, giocatoreId: string, i: number) {
|
||||||
|
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
evento_id: eventoId,
|
||||||
|
giocatore_id: giocatoreId,
|
||||||
|
stato: "presente",
|
||||||
|
risposto_il: `${dataEvento(i)}T08:30:00Z`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiEventi(): Promise<Evento[]> {
|
||||||
|
const res = await rest(
|
||||||
|
"eventi_app?id=like." +
|
||||||
|
PREFISSO +
|
||||||
|
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as RigaEvento[];
|
||||||
|
return righe.map(daRiga);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiPresenzeETempi(): Promise<{
|
||||||
|
presenze: MappaPresenze;
|
||||||
|
tempi: Record<string, Record<string, string>>;
|
||||||
|
}> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,stato,risposto_il`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
stato: Stato;
|
||||||
|
risposto_il: string;
|
||||||
|
}>;
|
||||||
|
const presenze: MappaPresenze = {};
|
||||||
|
const tempi: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) {
|
||||||
|
(presenze[r.evento_id] ??= {})[r.giocatore_id] = r.stato;
|
||||||
|
(tempi[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
|
||||||
|
}
|
||||||
|
return { presenze, tempi };
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(serieConferme: number, presenze: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
serieConferme,
|
||||||
|
presenze,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il segreto si sblocca solo quando entrambe le condizioni sono vere insieme",
|
||||||
|
async () => {
|
||||||
|
// "mf1": 15 eventi passati, presente e confermato in fretta a tutti — soddisfa
|
||||||
|
// ampiamente sia la serie di conferme (>=10) sia le presenze (>=15).
|
||||||
|
for (let i = 1; i <= 15; i += 1) {
|
||||||
|
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
|
||||||
|
await creaEvento(id, i);
|
||||||
|
await rispondi(id, "mf1", i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventi15 = await leggiEventi();
|
||||||
|
const { presenze: p15, tempi: t15 } = await leggiPresenzeETempi();
|
||||||
|
|
||||||
|
// Solo 9 eventi: serie di conferme appena sotto soglia, presenze sotto soglia.
|
||||||
|
const eventi9 = eventi15.slice(0, 9);
|
||||||
|
const serie9 = serieConferme("mf1", eventi9, t15, OGGI);
|
||||||
|
const pres9 = contaPresenzeGiocatore("mf1", eventi9, p15, OGGI);
|
||||||
|
assert.equal(serie9, 9);
|
||||||
|
assert.equal(pres9, 9);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(serie9, pres9)).grado,
|
||||||
|
null,
|
||||||
|
"9 conferme e 9 presenze: nessuna delle due soglie raggiunta",
|
||||||
|
);
|
||||||
|
|
||||||
|
const serie15 = serieConferme("mf1", eventi15, t15, OGGI);
|
||||||
|
const pres15 = contaPresenzeGiocatore("mf1", eventi15, p15, OGGI);
|
||||||
|
assert.equal(serie15, 15);
|
||||||
|
assert.equal(pres15, 15);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(serie15, pres15)).grado,
|
||||||
|
"oro",
|
||||||
|
"serie conferme >=10 e presenze >=15: il segreto si sblocca",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova("una risposta lenta rompe la serie ma non le presenze già accumulate", async () => {
|
||||||
|
// Un sedicesimo evento con risposta arrivata oltre le 24h: la serie conferme torna a 0,
|
||||||
|
// ma le presenze (indipendenti) restano a 16 — il segreto deve richiudersi, non serve
|
||||||
|
// ripartire da zero anche sulle presenze.
|
||||||
|
const idLento = `${PREFISSO}-a16`;
|
||||||
|
await creaEvento(idLento, 16);
|
||||||
|
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
evento_id: idLento,
|
||||||
|
giocatore_id: "mf1",
|
||||||
|
stato: "presente",
|
||||||
|
risposto_il: `${dataEvento(18)}T08:00:00Z`, // 2 giorni dopo la convocazione
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
|
||||||
|
|
||||||
|
const eventi = await leggiEventi();
|
||||||
|
const { presenze, tempi } = await leggiPresenzeETempi();
|
||||||
|
const serie = serieConferme("mf1", eventi, tempi, OGGI);
|
||||||
|
const conto = contaPresenzeGiocatore("mf1", eventi, presenze, OGGI);
|
||||||
|
assert.equal(serie, 0, "la risposta lenta azzera la serie di conferme");
|
||||||
|
assert.equal(conto, 16, "le presenze restano quelle di sempre, indipendenti dalla serie");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(def, giocatoreAzzerato(serie, conto)).grado,
|
||||||
|
null,
|
||||||
|
"presenze abbondanti ma serie azzerata: il segreto si richiude",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("s-mai-forfait-badge");
|
||||||
|
}
|
||||||
@@ -254,8 +254,8 @@ if (!locale) {
|
|||||||
|
|
||||||
// --- presenze: la risposta si cambia, il cronometro no --------------------------
|
// --- presenze: la risposta si cambia, il cronometro no --------------------------
|
||||||
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
|
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
|
||||||
// risposta, `aggiornato_il` è l'ultima modifica; oggi nessuna statistica le usa (la serie
|
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non
|
||||||
// «Conferme 24h» è stata rimossa). Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
|
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
|
||||||
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
|
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
|
||||||
await prova(
|
await prova(
|
||||||
"la risposta di presenza si aggiorna senza far ripartire il cronometro",
|
"la risposta di presenza si aggiorna senza far ripartire il cronometro",
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Badge Risposta lampo end-to-end contro il database locale:
|
||||||
|
* `bun test/integration/serie-conferme-badge.test.ts`.
|
||||||
|
*
|
||||||
|
* I test unitari (`test/unit/presenze.test.ts`, `test/unit/badges.test.ts`) verificano
|
||||||
|
* `serieConferme()` e `statoBadge()` come funzioni pure, con eventi e risposte costruiti a
|
||||||
|
* mano. Qui invece si scrivono allenamenti/partite ed eventi veri su `eventi_app`, e risposte
|
||||||
|
* vere su `risposte_presenze` con `creato_il`/`risposto_il` espliciti, si rileggono via REST
|
||||||
|
* con la stessa forma di `daRiga()`/`fetchPresenze()`, e si passa il risultato attraverso
|
||||||
|
* `serieConferme()` fino a `statoBadge()` sul badge `serie-conferme`: se una colonna cambia
|
||||||
|
* nome, o la mappatura `creato_il`/`risposto_il` -> `creatoIl`/`tempi` si rompe, qui il grado
|
||||||
|
* del badge torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano
|
||||||
|
* mai il database.
|
||||||
|
*
|
||||||
|
* Copre due comportamenti non ovvi con dati veri, non solo con numeri scelti a mano:
|
||||||
|
* - una risposta arrivata oltre le 24h dalla convocazione azzera la serie, anche dopo una
|
||||||
|
* striscia lunga;
|
||||||
|
* - partite e allenamenti contano nella stessa serie, senza bisogno di un filtro per tipo.
|
||||||
|
*
|
||||||
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
||||||
|
* usa id con il prefisso `test-serie-conferme-badge`, che nessun dato vero può avere.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
|
import { serieConferme } from "@/lib/presenze";
|
||||||
|
import { daRiga, type RigaEvento, type Evento } from "@/lib/eventi";
|
||||||
|
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||||
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
|
const locale = statoLocale();
|
||||||
|
|
||||||
|
if (!locale) {
|
||||||
|
salta("badge Risposta lampo sul database", "stack locale non attivo (npx supabase start)");
|
||||||
|
riepilogo("serie-conferme-badge");
|
||||||
|
} else {
|
||||||
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
||||||
|
console.log(`badge Risposta lampo su ${URL_BASE}`);
|
||||||
|
|
||||||
|
const PREFISSO = "test-serie-conferme-badge";
|
||||||
|
const serieDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
|
||||||
|
// "Oggi" fissato nel futuro: gli eventi scritti sotto sono datati nel passato remoto, così
|
||||||
|
// restano "passati" (quindi contati) a prescindere da quando gira il test.
|
||||||
|
const OGGI = "2099-01-01";
|
||||||
|
|
||||||
|
const rest = (percorso: string, init?: RequestInit) =>
|
||||||
|
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
apikey: SERVIZIO,
|
||||||
|
Authorization: `Bearer ${SERVIZIO}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** `2020-01-01` per i=1, avanzando di un giorno per ogni evento: niente collisioni di data. */
|
||||||
|
const dataEvento = (i: number) => {
|
||||||
|
const d = new Date(Date.UTC(2020, 0, 1));
|
||||||
|
d.setUTCDate(d.getUTCDate() + i - 1);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Istante di convocazione: le 8 del mattino del giorno dell'evento. */
|
||||||
|
const creazioneEvento = (i: number) => `${dataEvento(i)}T08:00:00Z`;
|
||||||
|
|
||||||
|
async function creaEvento(
|
||||||
|
id: string,
|
||||||
|
i: number,
|
||||||
|
tipo: "allenamento" | "partita" = "allenamento",
|
||||||
|
) {
|
||||||
|
const res = await rest("eventi_app", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id,
|
||||||
|
tipo,
|
||||||
|
titolo: `Evento ${id}`,
|
||||||
|
data: dataEvento(i),
|
||||||
|
creato_il: creazioneEvento(i),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`creazione evento ${id}: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rispondi(
|
||||||
|
eventoId: string,
|
||||||
|
giocatoreId: string,
|
||||||
|
stato: string,
|
||||||
|
rispostoIl: string,
|
||||||
|
) {
|
||||||
|
const res = await rest("risposte_presenze?on_conflict=evento_id,giocatore_id", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
evento_id: eventoId,
|
||||||
|
giocatore_id: giocatoreId,
|
||||||
|
stato,
|
||||||
|
risposto_il: rispostoIl,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`upsert su risposte_presenze: ${res.status} ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leggiEventi(): Promise<Evento[]> {
|
||||||
|
const res = await rest(
|
||||||
|
"eventi_app?id=like." +
|
||||||
|
PREFISSO +
|
||||||
|
"*&select=id,tipo,titolo,luogo,data,ora,note,convocati,campionato,casa,pagelle_chiuse,creato_il",
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as RigaEvento[];
|
||||||
|
return righe.map(daRiga);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stessa forma di `fetchPresenze()`: eventoId -> giocatoreId -> istante della risposta. */
|
||||||
|
async function leggiTempi(): Promise<Record<string, Record<string, string>>> {
|
||||||
|
const res = await rest(
|
||||||
|
`risposte_presenze?evento_id=like.${PREFISSO}*&select=evento_id,giocatore_id,risposto_il`,
|
||||||
|
);
|
||||||
|
const righe = (await res.json()) as Array<{
|
||||||
|
evento_id: string;
|
||||||
|
giocatore_id: string;
|
||||||
|
risposto_il: string;
|
||||||
|
}>;
|
||||||
|
const mappa: Record<string, Record<string, string>> = {};
|
||||||
|
for (const r of righe) (mappa[r.evento_id] ??= {})[r.giocatore_id] = r.risposto_il;
|
||||||
|
return mappa;
|
||||||
|
}
|
||||||
|
|
||||||
|
function giocatoreAzzerato(serieConferme: number): Giocatore {
|
||||||
|
return {
|
||||||
|
...giocatori[0]!,
|
||||||
|
serieConferme,
|
||||||
|
mvp: 0,
|
||||||
|
mediaVoto: 0,
|
||||||
|
votiPagella: 0,
|
||||||
|
palloni: 0,
|
||||||
|
presenze: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prova(
|
||||||
|
"il badge passa da bronzo a oro con conferme rapide vere, e una risposta lenta lo azzera",
|
||||||
|
async () => {
|
||||||
|
// 15 eventi passati, tutti confermati mezz'ora dopo la convocazione per "sc1":
|
||||||
|
// bronzo (3), poi argento (8), poi oro (15) man mano che si rileggono i dati.
|
||||||
|
for (let i = 1; i <= 15; i += 1) {
|
||||||
|
const id = `${PREFISSO}-a${String(i).padStart(2, "0")}`;
|
||||||
|
await creaEvento(id, i);
|
||||||
|
await rispondi(id, "sc1", "presente", `${dataEvento(i)}T08:30:00Z`);
|
||||||
|
}
|
||||||
|
const eventi15 = await leggiEventi();
|
||||||
|
const tempi15 = await leggiTempi();
|
||||||
|
|
||||||
|
const serieA3 = serieConferme("sc1", eventi15.slice(0, 3), tempi15, OGGI);
|
||||||
|
assert.equal(serieA3, 3);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieA3)).grado,
|
||||||
|
"bronzo",
|
||||||
|
"3 conferme rapide di fila: soglia bronzo",
|
||||||
|
);
|
||||||
|
|
||||||
|
const serieA8 = serieConferme("sc1", eventi15.slice(0, 8), tempi15, OGGI);
|
||||||
|
assert.equal(serieA8, 8);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieA8)).grado,
|
||||||
|
"argento",
|
||||||
|
"8 conferme rapide di fila: soglia argento",
|
||||||
|
);
|
||||||
|
|
||||||
|
const serieCompleta = serieConferme("sc1", eventi15, tempi15, OGGI);
|
||||||
|
assert.equal(serieCompleta, 15, "confermato in fretta a tutti e 15, nessun buco");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieCompleta)).grado,
|
||||||
|
"oro",
|
||||||
|
"15 conferme rapide di fila: soglia oro",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un sedicesimo evento con una risposta arrivata dopo 2 giorni azzera la serie.
|
||||||
|
const idLento = `${PREFISSO}-a16`;
|
||||||
|
await creaEvento(idLento, 16);
|
||||||
|
await rispondi(idLento, "sc1", "presente", `${dataEvento(18)}T08:00:00Z`);
|
||||||
|
const eventiConRitardo = await leggiEventi();
|
||||||
|
const tempiConRitardo = await leggiTempi();
|
||||||
|
const serieDopoRitardo = serieConferme("sc1", eventiConRitardo, tempiConRitardo, OGGI);
|
||||||
|
assert.equal(
|
||||||
|
serieDopoRitardo,
|
||||||
|
0,
|
||||||
|
"una risposta oltre le 24h azzera la serie, anche dopo 15 conferme di fila",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serieDopoRitardo)).grado,
|
||||||
|
null,
|
||||||
|
"serie azzerata: nessun grado, si riparte da zero",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await prova(
|
||||||
|
"partite e allenamenti contano nella stessa serie, senza filtro per tipo",
|
||||||
|
async () => {
|
||||||
|
// "sc2": conferma rapida su un allenamento, poi su una partita, poi su un altro
|
||||||
|
// allenamento: la serie sale a 3 senza bisogno che siano tutti dello stesso tipo.
|
||||||
|
await creaEvento(`${PREFISSO}-b01`, 30, "allenamento");
|
||||||
|
await creaEvento(`${PREFISSO}-b02`, 31, "partita");
|
||||||
|
await creaEvento(`${PREFISSO}-b03`, 32, "allenamento");
|
||||||
|
await rispondi(`${PREFISSO}-b01`, "sc2", "presente", `${dataEvento(30)}T08:30:00Z`);
|
||||||
|
await rispondi(`${PREFISSO}-b02`, "sc2", "presente", `${dataEvento(31)}T08:30:00Z`);
|
||||||
|
await rispondi(`${PREFISSO}-b03`, "sc2", "presente", `${dataEvento(32)}T08:30:00Z`);
|
||||||
|
|
||||||
|
const eventiLetti = await leggiEventi();
|
||||||
|
const tempiLetti = await leggiTempi();
|
||||||
|
const serie = serieConferme("sc2", eventiLetti, tempiLetti, OGGI);
|
||||||
|
assert.equal(serie, 3, "partita in mezzo a due allenamenti: la serie resta a 3");
|
||||||
|
assert.equal(
|
||||||
|
statoBadge(serieDef, giocatoreAzzerato(serie)).grado,
|
||||||
|
"bronzo",
|
||||||
|
"3 conferme rapide di fila, tipo misto: soglia bronzo",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rest(`risposte_presenze?evento_id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
riepilogo("serie-conferme-badge");
|
||||||
|
}
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/** Check del componente degli avvisi in Home: `bun test/unit/avviso.test.ts`. */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { CalendarClock, FileX } from "lucide-react";
|
|
||||||
import { CorpoAvviso, type TonoAvviso } from "@/components/crapp/Avviso";
|
|
||||||
|
|
||||||
const html = (p: Parameters<typeof CorpoAvviso>[0]) =>
|
|
||||||
renderToStaticMarkup(createElement(CorpoAvviso, p));
|
|
||||||
|
|
||||||
// --- ogni tono ha il suo colore, e l'icona è decorativa (il titolo dice già tutto) ---
|
|
||||||
const classi: Record<TonoAvviso, string> = {
|
|
||||||
critico: "bg-destructive",
|
|
||||||
attenzione: "bg-warning",
|
|
||||||
scaduto: "bg-primary",
|
|
||||||
azione: "bg-accent-grad",
|
|
||||||
};
|
|
||||||
for (const [tono, classe] of Object.entries(classi) as [TonoAvviso, string][]) {
|
|
||||||
const h = html({ tono, icona: FileX, titolo: "Titolo" });
|
|
||||||
assert.ok(h.includes(classe), `${tono}: classe colore ${classe}`);
|
|
||||||
assert.ok(h.includes("Titolo"), `${tono}: titolo`);
|
|
||||||
assert.ok(h.includes('aria-hidden="true"'), `${tono}: icona nascosta ai lettori di schermo`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- testi: un paragrafo per frase ---------------------------------------------------
|
|
||||||
const conTesti = html({
|
|
||||||
tono: "attenzione",
|
|
||||||
icona: CalendarClock,
|
|
||||||
titolo: "Certificato in scadenza",
|
|
||||||
testi: ["Prima frase.", "Seconda frase."],
|
|
||||||
});
|
|
||||||
assert.equal((conTesti.match(/<p /g) ?? []).length, 3, "titolo + due paragrafi");
|
|
||||||
assert.ok(!conTesti.includes("<ul"), "senza elenco niente lista");
|
|
||||||
|
|
||||||
// --- elenco: righe chi/cosa, conteggio solo con più di una riga ----------------------
|
|
||||||
const una = html({
|
|
||||||
tono: "critico",
|
|
||||||
icona: FileX,
|
|
||||||
titolo: "Certificati mancanti",
|
|
||||||
elenco: [{ chi: "Mario Rossi", cosa: "non caricato" }],
|
|
||||||
});
|
|
||||||
assert.ok(una.includes("Mario Rossi") && una.includes("non caricato"), "riga con chi e cosa");
|
|
||||||
assert.ok(!una.includes("persone"), "una sola riga: nessun conteggio");
|
|
||||||
|
|
||||||
const tre = html({
|
|
||||||
tono: "critico",
|
|
||||||
icona: FileX,
|
|
||||||
titolo: "Certificati mancanti",
|
|
||||||
elenco: [
|
|
||||||
{ chi: "Mario Rossi", cosa: "non caricato" },
|
|
||||||
{ chi: "Luca Bianchi", cosa: "non caricato" },
|
|
||||||
{ chi: "Anna Verdi", cosa: "non caricato" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
assert.equal((tre.match(/<li /g) ?? []).length, 3, "una riga per persona");
|
|
||||||
assert.ok(tre.includes('aria-label="3 persone"'), "conteggio accessibile");
|
|
||||||
|
|
||||||
// --- freccia solo se la card porta altrove -------------------------------------------
|
|
||||||
const base = { tono: "scaduto", icona: FileX, titolo: "t" } as const;
|
|
||||||
assert.ok(!html(base).includes("lucide-chevron-right"), "senza link niente freccia");
|
|
||||||
assert.ok(
|
|
||||||
html({ ...base, cliccabile: true }).includes("lucide-chevron-right"),
|
|
||||||
"con link la freccia",
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- etichetta e riquadri (turno palloni) --------------------------------------------
|
|
||||||
const palloni = html({
|
|
||||||
tono: "azione",
|
|
||||||
icona: FileX,
|
|
||||||
titolo: "Turno palloni",
|
|
||||||
etichetta: "Oggi",
|
|
||||||
testi: ["Porta i palloni."],
|
|
||||||
riquadri: true,
|
|
||||||
});
|
|
||||||
assert.ok(palloni.includes("Oggi"), "etichetta mostrata");
|
|
||||||
assert.ok(palloni.includes("bg-black/15"), "il messaggio è in un riquadro");
|
|
||||||
assert.ok(palloni.includes("bg-black/25"), "etichetta in pillola");
|
|
||||||
assert.ok(!html({ ...base }).includes("bg-black/25"), "senza etichetta niente pillola");
|
|
||||||
assert.ok(
|
|
||||||
!html({ ...base, testi: ["x"] }).includes("bg-black/15"),
|
|
||||||
"senza riquadri testo semplice",
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("avviso: ok");
|
|
||||||
@@ -34,6 +34,7 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
|||||||
streak: 0,
|
streak: 0,
|
||||||
serieAllenamenti: 0,
|
serieAllenamenti: 0,
|
||||||
seriePartite: 0,
|
seriePartite: 0,
|
||||||
|
serieConferme: 0,
|
||||||
...valori,
|
...valori,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -145,6 +146,16 @@ assert.equal(gradoRaggiunto(serieAllenamentiDef, 9), "argento");
|
|||||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro");
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 10), "oro");
|
||||||
assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro");
|
assert.equal(gradoRaggiunto(serieAllenamentiDef, 50), "oro", "oltre l'oro resta oro");
|
||||||
|
|
||||||
|
// --- serie-conferme: soglie 3 / 8 / 15 ----------------------------------------
|
||||||
|
const serieConfermeDef = badgeDefs.find((b) => b.id === "serie-conferme")!;
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 2), null, "sotto la prima soglia nessun grado");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 3), "bronzo", "la soglia è inclusiva");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 7), "bronzo");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 8), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 14), "argento");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 15), "oro");
|
||||||
|
assert.equal(gradoRaggiunto(serieConfermeDef, 50), "oro", "oltre l'oro resta oro");
|
||||||
|
|
||||||
// --- badgeGiocatore ----------------------------------------------------------
|
// --- badgeGiocatore ----------------------------------------------------------
|
||||||
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
|
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -189,6 +200,36 @@ assert.deepEqual(
|
|||||||
["s-cacche"],
|
["s-cacche"],
|
||||||
);
|
);
|
||||||
assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano");
|
assert.equal(badgeSegretiSbloccati(g({ cacche: 2 })).length, 0, "2 cacche non bastano");
|
||||||
|
assert.deepEqual(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 15 })).map((b) => b.def.id),
|
||||||
|
["s-mai-forfait"],
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 14 })).length,
|
||||||
|
0,
|
||||||
|
"servono entrambe le condizioni",
|
||||||
|
);
|
||||||
|
// Confini isolati: ogni soglia testata da sola, con l'altra abbondantemente sopra.
|
||||||
|
assert.equal(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 9, presenze: 30 })).length,
|
||||||
|
0,
|
||||||
|
"serieConferme appena sotto soglia, presenze abbondanti: non basta",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 10, presenze: 30 })).map((b) => b.def.id),
|
||||||
|
["s-mai-forfait"],
|
||||||
|
"serieConferme esattamente al confine, presenze abbondanti: sblocca",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 14 })).length,
|
||||||
|
0,
|
||||||
|
"presenze appena sotto soglia, serieConferme abbondante: non basta",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
badgeSegretiSbloccati(g({ serieConferme: 30, presenze: 15 })).map((b) => b.def.id),
|
||||||
|
["s-mai-forfait"],
|
||||||
|
"presenze esattamente al confine, serieConferme abbondante: sblocca",
|
||||||
|
);
|
||||||
|
|
||||||
// --- collezioneBadge ---------------------------------------------------------
|
// --- collezioneBadge ---------------------------------------------------------
|
||||||
const vuota = collezioneBadge(g());
|
const vuota = collezioneBadge(g());
|
||||||
@@ -221,6 +262,7 @@ assert.equal(
|
|||||||
palloni: 10,
|
palloni: 10,
|
||||||
presenze: 30,
|
presenze: 30,
|
||||||
serieAllenamenti: 10,
|
serieAllenamenti: 10,
|
||||||
|
serieConferme: 15,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import {
|
|||||||
convocatiEvento,
|
convocatiEvento,
|
||||||
daCategoria,
|
daCategoria,
|
||||||
daRiga,
|
daRiga,
|
||||||
eConvocato,
|
|
||||||
eventoVisibile,
|
|
||||||
rosaConvocata,
|
|
||||||
eventoVuoto,
|
eventoVuoto,
|
||||||
type RigaEvento,
|
type RigaEvento,
|
||||||
} from "@/lib/eventi";
|
} from "@/lib/eventi";
|
||||||
@@ -126,49 +123,3 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
|
|||||||
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
||||||
|
|
||||||
console.log("eventi: ok");
|
console.log("eventi: ok");
|
||||||
|
|
||||||
// --- eventoVisibile: i non convocati non vedono l'evento, chi gestisce sì -----
|
|
||||||
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g1", false), true, "il convocato lo vede");
|
|
||||||
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", false), false, "il non convocato no");
|
|
||||||
assert.equal(eventoVisibile({ convocati: ["g1"] }, null, false), false, "senza slot no");
|
|
||||||
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", true), true, "admin/allenatore sempre");
|
|
||||||
assert.equal(eventoVisibile({ convocati: [] }, "g2", false), true, "vuoto = tutta la rosa");
|
|
||||||
|
|
||||||
// --- eConvocato: elenco vuoto = tutti ----------------------------------------
|
|
||||||
assert.equal(eConvocato({ convocati: ["g1"] }, "g1"), true, "convocato");
|
|
||||||
assert.equal(eConvocato({ convocati: ["g1"] }, "g2"), false, "non convocato");
|
|
||||||
assert.equal(eConvocato({ convocati: [] }, "g2"), true, "vuoto = tutti");
|
|
||||||
|
|
||||||
// --- Copertura per numero di convocati: 1, 2, 3, tutti, nessuno, id sconosciuti -
|
|
||||||
const squadra = ["g1", "g2", "g3", "g4"].map((id) => ({ id }));
|
|
||||||
const ids = (l: Array<{ id: string }>) => l.map((g) => g.id);
|
|
||||||
const casi: Array<{ nome: string; convocati: string[]; attesi: string[] }> = [
|
|
||||||
{ nome: "nessuno indicato = tutta la rosa", convocati: [], attesi: ["g1", "g2", "g3", "g4"] },
|
|
||||||
{ nome: "solo 1", convocati: ["g2"], attesi: ["g2"] },
|
|
||||||
{ nome: "2", convocati: ["g1", "g4"], attesi: ["g1", "g4"] },
|
|
||||||
{ nome: "3", convocati: ["g3", "g1", "g2"], attesi: ["g1", "g2", "g3"] },
|
|
||||||
{ nome: "tutti indicati", convocati: ["g1", "g2", "g3", "g4"], attesi: ["g1", "g2", "g3", "g4"] },
|
|
||||||
{ nome: "id sconosciuto ignorato", convocati: ["g2", "zz"], attesi: ["g2"] },
|
|
||||||
{ nome: "solo ids sconosciuti: nessuno", convocati: ["zz"], attesi: [] },
|
|
||||||
];
|
|
||||||
for (const { nome, convocati, attesi } of casi) {
|
|
||||||
assert.deepEqual(ids(rosaConvocata(squadra, convocati)), attesi, `rosaConvocata: ${nome}`);
|
|
||||||
const conv = { convocati };
|
|
||||||
for (const { id } of squadra) {
|
|
||||||
const dentro = attesi.includes(id) || convocati.length === 0;
|
|
||||||
assert.equal(
|
|
||||||
eConvocato(conv, id),
|
|
||||||
convocati.length === 0 || convocati.includes(id),
|
|
||||||
`eConvocato ${id}: ${nome}`,
|
|
||||||
);
|
|
||||||
assert.equal(eventoVisibile(conv, id, false), dentro, `visibile a ${id}: ${nome}`);
|
|
||||||
assert.equal(
|
|
||||||
eventoVisibile(conv, id, true),
|
|
||||||
true,
|
|
||||||
`admin/allenatore vede sempre (${id}): ${nome}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert.equal(eventoVisibile(conv, null, false), convocati.length === 0, `senza slot: ${nome}`);
|
|
||||||
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
|
|
||||||
}
|
|
||||||
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
|
|
||||||
@@ -19,6 +19,7 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
|
|||||||
streak: 0,
|
streak: 0,
|
||||||
serieAllenamenti: 0,
|
serieAllenamenti: 0,
|
||||||
seriePartite: 0,
|
seriePartite: 0,
|
||||||
|
serieConferme: 0,
|
||||||
...valori,
|
...valori,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ assert.ok(
|
|||||||
// --- serie: solo sul traguardo esatto ----------------------------------------
|
// --- serie: solo sul traguardo esatto ----------------------------------------
|
||||||
assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3"));
|
assert.ok(ids(g({ serieAllenamenti: 3 })).includes("serie:allenamenti:3"));
|
||||||
assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo");
|
assert.ok(!ids(g({ serieAllenamenti: 4 })).includes("serie:allenamenti:4"), "4 non è un traguardo");
|
||||||
assert.ok(ids(g({ seriePartite: 5 })).includes("serie:partite:5"));
|
assert.ok(ids(g({ serieConferme: 8 })).includes("serie:conferme:8"));
|
||||||
|
|
||||||
// --- obiettivi di squadra: uno solo, tra il 90% e il 99% --------------------
|
// --- obiettivi di squadra: uno solo, tra il 90% e il 99% --------------------
|
||||||
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
|
const obiettivo = (id: string, valore: number): ObiettivoSquadra => ({
|
||||||
@@ -68,7 +69,6 @@ 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,9 +59,6 @@ const tipiNuovi: TipoNotifica[] = [
|
|||||||
"sollecita_presenze_24h",
|
"sollecita_presenze_24h",
|
||||||
"sollecita_presenze_12h",
|
"sollecita_presenze_12h",
|
||||||
"sollecita_presenze_6h",
|
"sollecita_presenze_6h",
|
||||||
"compleanno",
|
|
||||||
"compleanno_auguri",
|
|
||||||
"presenza_modificata",
|
|
||||||
];
|
];
|
||||||
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) {
|
||||||
@@ -70,13 +67,6 @@ 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(
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
/** Check della card di dettaglio degli obiettivi: `bun test/unit/obiettivi-dettaglio.test.ts`. */
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createElement } from "react";
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
|
||||||
import { SchedaObiettivo } from "@/components/crapp/ObiettivoDrawer";
|
|
||||||
import { Drawer } from "@/components/ui/drawer";
|
|
||||||
import { giocatori } from "@/lib/crapp-data";
|
|
||||||
import { contestoVuoto, obiettiviSquadra, type ObiettivoSquadra } from "@/lib/obiettivi";
|
|
||||||
|
|
||||||
const OGGI = new Date("2026-08-15T10:00:00Z");
|
|
||||||
const tutti = obiettiviSquadra(giocatori, contestoVuoto, OGGI);
|
|
||||||
|
|
||||||
/** Rende la scheda dentro un Drawer aperto, come fa l'app (titolo e descrizione ne hanno bisogno). */
|
|
||||||
const scheda = (o: ObiettivoSquadra) =>
|
|
||||||
renderToStaticMarkup(
|
|
||||||
createElement(Drawer, { open: true }, createElement(SchedaObiettivo, { o })),
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- ogni obiettivo ha tutti i testi, nessun campo vuoto ----------------------------
|
|
||||||
const ids = tutti.map((o) => o.id).sort();
|
|
||||||
assert.deepEqual(
|
|
||||||
ids,
|
|
||||||
["o1", "o11", "o12", "o13", "o2", "o3", "o4", "o5", "o6", "o7"],
|
|
||||||
"gli obiettivi sono quelli attesi: se ne aggiungi uno, scrivi il suo dettaglio",
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const o of tutti) {
|
|
||||||
const d = o.dettaglio;
|
|
||||||
for (const [nome, testo] of Object.entries({
|
|
||||||
comeSiCalcola: d.comeSiCalcola,
|
|
||||||
periodo: d.periodo,
|
|
||||||
fonte: d.fonte,
|
|
||||||
esempio: d.esempio,
|
|
||||||
})) {
|
|
||||||
assert.ok(testo.trim().length > 20, `${o.id}: «${nome}» troppo corto o vuoto`);
|
|
||||||
}
|
|
||||||
for (const voce of [...d.conta, ...d.nonConta, ...(d.perse ?? [])]) {
|
|
||||||
assert.ok(voce.trim().length > 10, `${o.id}: voce di elenco vuota o troppo corta`);
|
|
||||||
}
|
|
||||||
assert.equal(new Set(d.conta).size, d.conta.length, `${o.id}: voci «conta» duplicate`);
|
|
||||||
assert.equal(new Set(d.nonConta).size, d.nonConta.length, `${o.id}: voci «non conta» duplicate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- «presenza persa» solo sulle due percentuali di presenze ------------------------
|
|
||||||
for (const o of tutti) {
|
|
||||||
const haPerse = Boolean(o.dettaglio.perse && o.dettaglio.perse.length > 0);
|
|
||||||
assert.equal(haPerse, o.id === "o1" || o.id === "o7", `${o.id}: presenza persa solo su o1 e o7`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- le due presenze spiegano gli stessi punti chiave -------------------------------
|
|
||||||
for (const id of ["o1", "o7"]) {
|
|
||||||
const d = tutti.find((o) => o.id === id)!.dettaglio;
|
|
||||||
const tutto = [d.comeSiCalcola, ...d.conta, ...(d.perse ?? []), ...d.nonConta].join(" ");
|
|
||||||
for (const parola of ["convocat", "presente", "in ritardo", "assente", "cene"]) {
|
|
||||||
assert.ok(tutto.toLowerCase().includes(parola), `${id}: la spiegazione non cita «${parola}»`);
|
|
||||||
}
|
|
||||||
assert.ok(
|
|
||||||
d.nonConta.some((v) => /non è convocato/i.test(v)),
|
|
||||||
`${id}: dice che il non convocato non pesa`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const mese = tutti.find((o) => o.id === "o1")!.dettaglio;
|
|
||||||
const totale = tutti.find((o) => o.id === "o7")!.dettaglio;
|
|
||||||
assert.ok(/mese/i.test(mese.periodo) && /azzera/i.test(mese.periodo), "o1: si azzera ogni mese");
|
|
||||||
assert.ok(/stagione/i.test(totale.periodo) && /mai/i.test(totale.periodo), "o7: non si azzera mai");
|
|
||||||
|
|
||||||
// --- la card renderizzata mostra tutte le sezioni ------------------------------------
|
|
||||||
const o1 = tutti.find((o) => o.id === "o1")!;
|
|
||||||
const html1 = scheda(o1);
|
|
||||||
for (const titolo of [
|
|
||||||
"Stato attuale",
|
|
||||||
"Come si calcola",
|
|
||||||
"Cosa conta",
|
|
||||||
"Presenza persa",
|
|
||||||
"Non entra nel calcolo",
|
|
||||||
"Quando vale",
|
|
||||||
"Da dove arrivano i dati",
|
|
||||||
"Un esempio",
|
|
||||||
]) {
|
|
||||||
assert.ok(html1.includes(titolo), `la card di o1 non mostra «${titolo}»`);
|
|
||||||
}
|
|
||||||
assert.ok(html1.includes(o1.titolo.replace(/&/g, "&")), "titolo");
|
|
||||||
assert.ok(html1.includes(o1.dettaglio.comeSiCalcola), "testo «come si calcola»");
|
|
||||||
assert.ok(html1.includes(o1.impatto), "frase di impatto");
|
|
||||||
assert.ok(html1.includes("entro il"), "o1 ha una scadenza mensile");
|
|
||||||
assert.ok(
|
|
||||||
!html1.includes("Cosa non conta"),
|
|
||||||
"con «presenza persa» la sezione si chiama diversamente",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Un obiettivo senza «presenza persa» ha «Cosa non conta» e nessuna sezione in più.
|
|
||||||
const o12 = tutti.find((o) => o.id === "o12")!;
|
|
||||||
const html12 = scheda(o12);
|
|
||||||
assert.ok(html12.includes("Cosa non conta"), "o12: «Cosa non conta»");
|
|
||||||
assert.ok(!html12.includes("Presenza persa"), "o12: nessuna «presenza persa»");
|
|
||||||
assert.ok(!html12.includes("Non entra nel calcolo"), "o12: nessun «non entra nel calcolo»");
|
|
||||||
assert.ok(!html12.includes("entro il"), "o12 non ha scadenza");
|
|
||||||
|
|
||||||
// --- stato: in corso / completato, e i numeri mostrati --------------------------------
|
|
||||||
const finto = (valore: number): ObiettivoSquadra => ({ ...o12, valore, target: 10 });
|
|
||||||
assert.ok(scheda(finto(4)).includes("In corso"), "sotto il target: in corso");
|
|
||||||
assert.ok(scheda(finto(4)).includes("40%"), "percentuale mostrata");
|
|
||||||
assert.ok(scheda(finto(10)).includes("Completato"), "al target: completato");
|
|
||||||
assert.ok(!scheda(finto(10)).includes("In corso"), "al target non è più in corso");
|
|
||||||
|
|
||||||
console.log("obiettivi-dettaglio: ok");
|
|
||||||
+17
-335
@@ -100,169 +100,6 @@ 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.
|
||||||
@@ -379,7 +216,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-20T10:00:00Z");
|
const OGGI_SETTEMBRE = new Date("2026-09-05T10:00:00Z");
|
||||||
const pizzataSettembre: ContestoObiettivi = {
|
const pizzataSettembre: ContestoObiettivi = {
|
||||||
eventi: [evento("p1", "2026-09-12", "evento")],
|
eventi: [evento("p1", "2026-09-12", "evento")],
|
||||||
presenze: {},
|
presenze: {},
|
||||||
@@ -396,85 +233,6 @@ 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: {},
|
||||||
@@ -487,85 +245,23 @@ assert.equal(trova(obiettiviSquadra(giocatori, soloCompleanni, OGGI_AGOSTO), "o2
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- o7: presenze collettive (% su allenamenti e partite definiti, passati e futuri) ---
|
// --- somme sulla rosa --------------------------------------------------------
|
||||||
{
|
const sommaPresenze = giocatori.reduce((s, g) => s + g.presenze, 0);
|
||||||
const [g1, g2, g3, g4] = giocatori;
|
assert.equal(trova(vuoti, "o7").valore, sommaPresenze);
|
||||||
const rosa4 = [g1!, g2!, g3!, g4!];
|
assert.equal(
|
||||||
const o7 = (c: ContestoObiettivi, r = rosa4) => trova(obiettiviSquadra(r, c, OGGI_AGOSTO), "o7");
|
trova(obiettiviSquadra([], contestoVuoto), "o7").valore,
|
||||||
|
0,
|
||||||
|
"rosa vuota: nessuna presenza",
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(o7(contestoVuoto).titolo, "Presenze collettive");
|
// Valori noti e indipendenti dai dati reali della rosa: non solo la stessa formula
|
||||||
assert.equal(o7(contestoVuoto).target, 90);
|
// ricalcolata sugli stessi dati, ma una somma verificabile a mente (5 + 10 + 15 = 30).
|
||||||
assert.equal(o7(contestoVuoto).valore, 0, "nessun evento: 0, non NaN");
|
const rosaControllata = giocatori.slice(0, 3).map((g, i) => ({ ...g, presenze: [5, 10, 15][i]! }));
|
||||||
assert.equal(o7({ ...contestoVuoto }, []).valore, 0, "rosa vuota: 0");
|
assert.equal(
|
||||||
|
trova(obiettiviSquadra(rosaControllata, contestoVuoto), "o7").valore,
|
||||||
// Allenamento passato (3 presenti su 4) + partita futura (1 presente su 4): 4 su 8 = 50%.
|
30,
|
||||||
const base: ContestoObiettivi = {
|
"somma di presenze note, indipendente dal roster reale",
|
||||||
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 },
|
||||||
@@ -692,7 +388,6 @@ 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);
|
||||||
@@ -722,17 +417,4 @@ assert.equal(microcopyObiettivo(o(60, 100)), "Oltre metà strada: ancora 40 %.")
|
|||||||
assert.equal(microcopyObiettivo(o(10, 100)), "Si parte: 90 % al traguardo.");
|
assert.equal(microcopyObiettivo(o(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");
|
||||||
+84
-33
@@ -7,6 +7,7 @@ import {
|
|||||||
contaPresenzeGiocatore,
|
contaPresenzeGiocatore,
|
||||||
destinatariSollecito,
|
destinatariSollecito,
|
||||||
includeInfortunato,
|
includeInfortunato,
|
||||||
|
serieConferme,
|
||||||
serieConsecutiva,
|
serieConsecutiva,
|
||||||
testoSollecito,
|
testoSollecito,
|
||||||
totaliEventiGiocatore,
|
totaliEventiGiocatore,
|
||||||
@@ -217,9 +218,91 @@ assert.ok(
|
|||||||
"senza luogo la riga manca",
|
"senza luogo la riga manca",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- serie conferme: risposta entro 24h dalla convocazione --------------------
|
||||||
|
const convocati = (id: string, data: string, creatoIl?: string): Evento => ({
|
||||||
|
...ev(id, "allenamento", data),
|
||||||
|
...(creatoIl === undefined ? {} : { creatoIl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const conConvocazione: Evento[] = [
|
||||||
|
convocati("c1", "2026-08-06", "2026-08-01T10:00:00Z"),
|
||||||
|
convocati("c2", "2026-08-13", "2026-08-08T10:00:00Z"),
|
||||||
|
convocati("c3", "2026-08-20", "2026-08-15T10:00:00Z"),
|
||||||
|
convocati("c4", "2026-08-27"), // evento generato dal client: nessuna convocazione tracciata
|
||||||
|
convocati("c5", "2026-09-10", "2026-09-05T10:00:00Z"), // futuro
|
||||||
|
];
|
||||||
|
|
||||||
|
const tempi: MappaTempiRisposta = {
|
||||||
|
c1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo
|
||||||
|
c2: { g1: "2026-08-10T10:00:00Z" }, // due giorni dopo: buco
|
||||||
|
c3: { g1: "2026-08-16T09:59:00Z" }, // appena dentro le 24h
|
||||||
|
c5: { g1: "2026-09-05T10:30:00Z" },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(serieConferme("g1", conConvocazione, tempi, OGGI), 1, "il ritardo su c2 azzera");
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", conConvocazione.slice(0, 1), tempi, OGGI),
|
||||||
|
1,
|
||||||
|
"una risposta rapida vale 1",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", [conConvocazione[0]!, conConvocazione[3]!, conConvocazione[2]!], tempi, OGGI),
|
||||||
|
2,
|
||||||
|
"un evento senza istante di convocazione viene saltato, non spezza la serie",
|
||||||
|
);
|
||||||
|
assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero");
|
||||||
|
|
||||||
|
// Chi non è convocato non spezza la serie conferme di nessun altro (stesso filtro condiviso
|
||||||
|
// con `serieConsecutiva`, vedi sopra).
|
||||||
|
const c6Ristretto: Evento = {
|
||||||
|
...convocati("c6", "2026-08-08", "2026-08-03T10:00:00Z"),
|
||||||
|
convocati: ["g9"],
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", [conConvocazione[0]!, c6Ristretto], tempi, OGGI),
|
||||||
|
1,
|
||||||
|
"un evento convocato solo per un altro giocatore resta fuori, la serie di g1 non ne risente",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Le conferme contano partite e allenamenti insieme: nessuna versione per tipo.
|
||||||
|
const mistiTipo: Evento[] = [
|
||||||
|
convocati("m1", "2026-08-06", "2026-08-01T10:00:00Z"),
|
||||||
|
{ ...convocati("m2", "2026-08-13", "2026-08-08T10:00:00Z"), tipo: "partita" },
|
||||||
|
];
|
||||||
|
const tempiMisti: MappaTempiRisposta = {
|
||||||
|
m1: { g1: "2026-08-01T11:00:00Z" }, // un'ora dopo
|
||||||
|
m2: { g1: "2026-08-08T11:00:00Z" }, // un'ora dopo, ma è una partita
|
||||||
|
};
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", mistiTipo, tempiMisti, OGGI),
|
||||||
|
2,
|
||||||
|
"partite e allenamenti si sommano nella stessa serie",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Il confronto con le 24h è inclusivo: esattamente al bordo conta, un secondo oltre azzera.
|
||||||
|
const bordo: Evento[] = [convocati("b1", "2026-08-06", "2026-08-01T10:00:00Z")];
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:00Z" } }, OGGI),
|
||||||
|
1,
|
||||||
|
"esattamente 24h dopo: il confronto è <=, quindi conta",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", bordo, { b1: { g1: "2026-08-02T10:00:01Z" } }, OGGI),
|
||||||
|
0,
|
||||||
|
"un secondo oltre le 24h azzera",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un evento futuro non conta ancora, anche con una risposta rapidissima già registrata.
|
||||||
|
const futuro: Evento[] = [convocati("f1", "2026-09-10", "2026-09-05T10:00:00Z")];
|
||||||
|
assert.equal(
|
||||||
|
serieConferme("g1", futuro, { f1: { g1: "2026-09-05T10:05:00Z" } }, OGGI),
|
||||||
|
0,
|
||||||
|
"l'evento di domani non è ancora passato, non entra nel calcolo",
|
||||||
|
);
|
||||||
|
|
||||||
// --- cache locale dopo una risposta: il cronometro non riparte ----------------
|
// --- cache locale dopo una risposta: il cronometro non riparte ----------------
|
||||||
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
|
// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela.
|
||||||
// Qui la cache deve imitarla, altrimenti la cache mente fino al refresh.
|
// Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh.
|
||||||
const PRIMA = "2026-08-01T10:00:00Z";
|
const PRIMA = "2026-08-01T10:00:00Z";
|
||||||
const POI = "2026-08-08T10:00:00Z";
|
const POI = "2026-08-08T10:00:00Z";
|
||||||
|
|
||||||
@@ -294,35 +377,3 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log("presenze: ok");
|
console.log("presenze: ok");
|
||||||
|
|
||||||
// --- Solleciti per numero di convocati: 1, 2, 3, tutti ------------------------
|
|
||||||
{
|
|
||||||
const sq = (["g1", "g2", "g3", "g4"] as const).map((id) => ({
|
|
||||||
id,
|
|
||||||
attivo: true,
|
|
||||||
tipo: "giocatore" as const,
|
|
||||||
}));
|
|
||||||
const nessuna: Array<{ giocatore_id: string; stato: string }> = [];
|
|
||||||
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g2"]), ["g2"], "solo 1 convocato");
|
|
||||||
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g1", "g3"]), ["g1", "g3"], "2 convocati");
|
|
||||||
assert.deepEqual(
|
|
||||||
destinatariSollecito(sq, nessuna, ["g1", "g2", "g4"]),
|
|
||||||
["g1", "g2", "g4"],
|
|
||||||
"3 convocati",
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
destinatariSollecito(sq, [{ giocatore_id: "g1", stato: "presente" }], ["g1", "g3"]),
|
|
||||||
["g3"],
|
|
||||||
"il convocato che ha già risposto non va sollecitato",
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
destinatariSollecito(sq, [{ giocatore_id: "g4", stato: "presente" }], ["g1"]),
|
|
||||||
["g1"],
|
|
||||||
"chi ha risposto ma non è convocato non cambia i destinatari",
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
destinatariSollecito(sq, nessuna, []),
|
|
||||||
["g1", "g2", "g3", "g4"],
|
|
||||||
"nessuno = tutti",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,12 +57,6 @@ 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: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
|||||||
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
|
import { aggiornaSerie, serieDefs, serieGiocatore, serieMigliore, statoSerie } from "@/lib/serie";
|
||||||
|
|
||||||
function g(valori: Partial<Giocatore> = {}): Giocatore {
|
function g(valori: Partial<Giocatore> = {}): Giocatore {
|
||||||
return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, ...valori };
|
return { ...giocatori[0]!, serieAllenamenti: 0, seriePartite: 0, serieConferme: 0, ...valori };
|
||||||
}
|
}
|
||||||
|
|
||||||
const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!;
|
const allenamenti = serieDefs.find((d) => d.tipo === "allenamenti")!;
|
||||||
@@ -45,16 +45,16 @@ assert.deepEqual([fuoriScala.prossimo, fuoriScala.manca, fuoriScala.progresso],
|
|||||||
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
|
assert.equal(fuoriScala.messaggio, "Serie leggendaria: sei fuori scala!");
|
||||||
|
|
||||||
// --- serieGiocatore / serieMigliore ------------------------------------------
|
// --- serieGiocatore / serieMigliore ------------------------------------------
|
||||||
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 6 }));
|
const tutte = serieGiocatore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
|
||||||
assert.equal(tutte.length, serieDefs.length, "due serie indipendenti");
|
assert.equal(tutte.length, serieDefs.length, "tre serie indipendenti");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tutte.map((s) => s.valore),
|
tutte.map((s) => s.valore),
|
||||||
[4, 6],
|
[4, 1, 9],
|
||||||
"ogni serie legge il proprio contatore",
|
"ogni serie legge il proprio contatore",
|
||||||
);
|
);
|
||||||
|
|
||||||
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 6 }));
|
const migliore = serieMigliore(g({ serieAllenamenti: 4, seriePartite: 1, serieConferme: 9 }));
|
||||||
assert.equal(migliore.def.tipo, "partite", "in home si mostra la serie più lunga");
|
assert.equal(migliore.def.tipo, "conferme", "in home si mostra la serie più lunga");
|
||||||
assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare");
|
assert.ok(serieMigliore(g()), "anche a zero c'è sempre una serie da mostrare");
|
||||||
|
|
||||||
// --- invarianti sulle definizioni --------------------------------------------
|
// --- invarianti sulle definizioni --------------------------------------------
|
||||||
|
|||||||
Reference in new issue
Block a user