Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49351e536 |
No files matched your search
+13
-21
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 02/11/2026
|
||||
Ultimo aggiornamento: 01/10/2026
|
||||
|
||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||
@@ -9,39 +9,31 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
||||
|
||||
## Stato generale
|
||||
|
||||
- **Versione:** 1.6.0 su `main` (tag `v1.6.0`), pubblicata il 02/11/2026: voto MVP definitivo, segreto e a
|
||||
tempo (DD-051, DD-052) e Scout live a valutazioni − / + (DD-053). La 1.5.0: campionato CSI sulla stagione 2026/27 (nuovi `project_id` e `team_id`,
|
||||
nessuna Coppa). La 1.4.2: ogni evento può avere un link Google Maps del luogo, mostrato come
|
||||
«Apri su Maps» nelle schede (`m34`). La 1.4.1: gli eventi con convocati si
|
||||
vedono solo ai convocati (admin e allenatori sempre) e «Rosa e presenze» elenca solo loro. La 1.4.0:
|
||||
avviso «Presenza modificata» ad admin e allenatori (DD-050, `m32`-`m33`). La 1.3.1: il Calendario
|
||||
mostra solo i prossimi 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).
|
||||
- **Versione:** 1.2.3 (in produzione su `main` la 1.2.2) — notifiche su tre canali, avvisi di palloni
|
||||
(3 ore prima) e presenze (24 ore prima) una sola volta, niente promemoria evento, niente foto
|
||||
tessera, avviso certificato a 30 giorni per il giocatore, avviso rosso per chi non l'ha caricato.
|
||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||
- **Migration:** 50 file in `supabase/migrations/`, fino a `m39_scout_valutazioni`. In produzione sono
|
||||
applicate tutte e 50 (`npx supabase db push` del 02/11/2026: `m35`-`m39`, verificato con
|
||||
`npx supabase migration list --linked`; nessuna migration remota è assente dal repository). Prima
|
||||
del push è stato salvato lo schema di produzione (solo schema, non i dati).
|
||||
- **Migration:** 41 file in `supabase/migrations/`, fino a `m30_sollecito_solo_24h`, tutte
|
||||
applicate in produzione (verificato con `npx supabase migration list` il 01/10/2026). La `m29` toglie
|
||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
||||
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||
è su `main` dalla 1.3.0.
|
||||
è su `develop`, non ancora su `main`.
|
||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
||||
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
||||
collega al primo accesso (DD-018).
|
||||
- **Lavoro in corso:** nessuno su branch. Le voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto
|
||||
«Prossimo». Da decidere sullo scout: il pulsante «Annulla» in alto agisce solo sulle azioni, che non
|
||||
si creano più, quindi è sempre spento.
|
||||
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
## Cose da sapere
|
||||
|
||||
- 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
|
||||
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
|
||||
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
|
||||
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99
|
||||
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
|
||||
presenti.
|
||||
|
||||
## Note
|
||||
|
||||
|
||||
@@ -85,13 +85,9 @@ quando il database non risponde.
|
||||
## UI
|
||||
|
||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `Card`, `PageHeader`,
|
||||
`Section`, `StatTile`; `Avviso.tsx` per gli avvisi colorati in Home; i drawer di dettaglio `BadgeDrawer` e `ObiettivoDrawer`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
`Section`, `StatTile`), animazioni in `src/components/motion/`. Mobile-first (DD-005): poche
|
||||
schermate, pochi click.
|
||||
|
||||
**Caratteri** (DD-049): niente testo sotto i 13 px, tranne la navigazione a pillola e i badge
|
||||
da 10-11 px. Scala in uso: 13 px note, 15 px corpo, 16 px titoli di card; si usano le misure
|
||||
fisse `text-[13px]`…`text-[16px]`, non `text-xs` e `text-sm`.
|
||||
|
||||
In `src/components/ui/` restano solo le due primitive shadcn davvero usate, `drawer` (vaul) e
|
||||
`sonner`: le altre 43 non erano importate da nessuna parte (DD-021). Il resto dell'interfaccia
|
||||
è composto con Tailwind e la primitiva `Card`, che è l'unica definizione di raggio, sfondo e
|
||||
|
||||
+1
-172
@@ -13,186 +13,15 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.6.0] - 2026-11-02
|
||||
|
||||
Voto MVP definitivo e segreto, Scout live a valutazioni. Cinque migration, da `m35` a `m39`,
|
||||
applicate in produzione il 02/11/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Scout live: per ogni convocato si può segnare un − o un + su battuta, ricezione, attacco e muro,
|
||||
separatamente per ogni set e senza toccare il punteggio ([DD-053](decisions/DD-053.md), `m39`). Il
|
||||
CSV admin della partita include una tabella con i conteggi per set.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Scout live: tolti i tasti dei punti, il tabellone del punteggio, l'elenco «Ultime azioni» e il
|
||||
blocco avversario / in casa / trasferta; resta la scelta del giocatore e la valutazione − / + per set
|
||||
([DD-053](decisions/DD-053.md)). «Chiudi set» e «Fine partita» funzionano anche senza punti; le
|
||||
partite di sole valutazioni non compaiono come risultato in Home, Classifica e pagina partita.
|
||||
- Scout live: il salvataggio è immediato a ogni tocco (prima c'era una pausa di 0,8 secondi) e,
|
||||
se non riesce, compare un avviso.
|
||||
- Scout live: la griglia «Selezione giocatore» si ordina per cognome o per numero; nel riquadro di
|
||||
ogni giocatore compaiono i + del set in corso (ricezione esclusa) al posto dei punti.
|
||||
- Scout live: dopo «Fine partita» il messaggio dice solo «Partita salvata» (il voto MVP apre 2 ore
|
||||
dopo l'inizio della partita, non dipende dallo scout).
|
||||
- Il voto MVP è definitivo ([DD-051](decisions/DD-051.md)): si sceglie un nome, si conferma e non si
|
||||
può più cambiare. Il database lo impone (solo gli admin possono correggere un voto).
|
||||
- In caso di parità in testa gli MVP sono più d'uno: ognuno dei pari merito conta un titolo, e in
|
||||
Home e Classifica compaiono i nomi uniti («Anna e Bruno»). Resta il minimo di 2 voti per eleggere
|
||||
un MVP.
|
||||
- Il voto MVP è segreto ([DD-052](decisions/DD-052.md)): nessuno vede chi ha votato chi, solo i
|
||||
conteggi e il proprio voto.
|
||||
- La votazione MVP si chiude 24 ore dopo l'apertura (cioè 26 ore dopo l'inizio della partita): le
|
||||
partite più vecchie non si votano più.
|
||||
|
||||
- I test non pretendono più una gara CSI già giocata: a inizio stagione il calendario c'è ma i
|
||||
risultati no (un test si salta finché non c'è una gara giocata).
|
||||
|
||||
### Sicurezza
|
||||
|
||||
- Il database impone anche le regole che prima valevano solo nell'app: il voto MVP è possibile solo per
|
||||
una partita, tra presenti (`presente` o `ritardo`) e dopo 2 ore dall'inizio (`m36`,
|
||||
[DD-051](decisions/DD-051.md)). Gli admin possono ancora correggere un voto.
|
||||
- Il nome del votato lo scrive il database dalla rosa, per i voti MVP (`m37`) e per i badge social
|
||||
(`m38`): un client non può più salvare un nome diverso da quello del giocatore votato. In
|
||||
produzione non risultavano voti MVP, quindi nessun dato da bonificare.
|
||||
|
||||
## [1.5.0] - 2026-10-29
|
||||
|
||||
Campionato CSI 2026/27. Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- Campionato CSI passato alla stagione 2026/27: nuovo `project_id` (933) e nuovo `team_id`
|
||||
(3878). La Coppa non c'è quest'anno e la sezione resta nascosta; lo storico 2025/26 non viene
|
||||
più mostrato.
|
||||
|
||||
### Dati
|
||||
|
||||
- Calendario di produzione aggiornato a mano con le 12 gare di campionato già definite dal CSI
|
||||
(4/11/2026 – 11/3/2027): titolo «CAMPIONATO», luogo e link Google Maps dell'impianto. I 9
|
||||
allenamenti nello stesso giorno di una gara sono stati eliminati.
|
||||
|
||||
## [1.4.2] - 2026-10-26
|
||||
|
||||
Link Google Maps del luogo degli eventi. Una migration, `m34`, applicata in produzione il
|
||||
26/10/2026.
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- Link Google Maps del luogo: in «Gestione eventi» (admin e allenatori) un campo facoltativo
|
||||
accetta il link copiato da Google Maps; nelle schede di partita, allenamento ed evento compare
|
||||
«Apri su Maps» solo se il link c'è. Non compare nella card né nelle notifiche. Il link
|
||||
è validato (solo https di Google Maps) sia nell'app sia da un CHECK sul database.
|
||||
|
||||
## [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
|
||||
|
||||
Nella Home si vede anche chi non ha caricato il certificato medico, e le dipendenze sono aggiornate perché Vercel bloccava il deploy. Nessuna migration.
|
||||
Nella Home si vede anche chi non ha caricato il certificato medico. Nessuna migration.
|
||||
|
||||
### Modificato
|
||||
|
||||
- **Avviso certificati mancanti** — nella Home chi non ha caricato il certificato medico compare in una card rossa:
|
||||
il giocatore vede il proprio avviso, l'admin l'elenco di chi manca, sopra scaduti e in scadenza (DD-046).
|
||||
|
||||
### Sicurezza
|
||||
|
||||
- **Dipendenze aggiornate** — `@tanstack/react-start` dalla 1.168.34 alla 1.168.60 (con `react-router`, `router-plugin` e `react-query`): Vercel bloccava il deploy per la versione precedente. `npm audit fix` chiude anche le tre vulnerabilità alte di `brace-expansion` e `nanoid`; `npm audit` segnala 0 vulnerabilità.
|
||||
|
||||
## [1.2.2] - 2026-10-01
|
||||
|
||||
Meno notifiche automatiche e un profilo più snello: gli avvisi dei palloni e delle presenze partono una sola volta, il
|
||||
|
||||
+39
-42
@@ -12,21 +12,20 @@ allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura**
|
||||
resta aperta a tutti gli autenticati su ogni tabella di questo elenco, con le eccezioni della
|
||||
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
|
||||
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli. Su `mvp_voti` (`m35`-`m37`, DD-051, DD-052) il giocatore può solo inserire il voto, per una partita aperta al voto (da 2 a 26 ore dopo l'inizio) e tra presenti (`voto_mvp_consentito()`), e leggere solo il proprio; modifica, cancellazione e lettura degli altri voti sono degli admin |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
| Tabella | Chi può scrivere |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
||||
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin |
|
||||
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
|
||||
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
|
||||
| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie |
|
||||
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) |
|
||||
|
||||
L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`.
|
||||
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
|
||||
@@ -59,7 +58,7 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `email` | Chiave del collegamento automatico account↔giocatore al primo accesso (DD-018). Impostabile da `/admin`. |
|
||||
| `auth_user_id` | Collega lo slot all'account; NULL finché nessuno lo reclama. |
|
||||
| `numero_tessera`, `data_tessera` | Non più usate dall'interfaccia (DD-048): i valori già inseriti restano, nessuna schermata li mostra o li scrive. Il trigger le include ancora tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `numero_tessera`, `data_tessera` | Chi è già tesserato al CSI. Come `numero` e `ruolo` le scrive solo un admin: il trigger le include tra i campi bloccati per chi reclama il proprio slot. |
|
||||
| `nascita` | Pubblica a tutta la rosa (DD-031). Non si scrive mai da qui: il trigger `sincronizza_nascita_pubblica` su `profili_giocatore` la tiene allineata a `data_nascita` (impostata, aggiornata o azzerata). |
|
||||
| `tipo` | `giocatore` o `allenatore` (DD-034). L'allenatore ha uno slot per collegarsi e per i dati personali ma è fuori dalla rosa di gioco (`inRosa()`), con `numero` NULL (vincolo `giocatori_squadra_numero_giocatori`) e `ruolo` vuoto. Lo scrive solo un admin. |
|
||||
|
||||
@@ -74,13 +73,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
||||
|
||||
## Eventi e presenze
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `link_maps` (M34) è il link Google Maps facoltativo del luogo, con CHECK che accetta solo URL https di Google Maps. 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. |
|
||||
| `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). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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). |
|
||||
|
||||
## Scout
|
||||
|
||||
@@ -88,18 +87,18 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `scout_sessioni` | Chi ha il controllo dello Scout Live per una partita (blocco condiviso), una riga per evento. | Letta/scritta da `src/lib/scout-live.ts`. |
|
||||
| `scout_live` | Stato in corso (azioni non ancora concluse) di una sessione. | Solo per statistiche di squadra, mai classifiche individuali (DD-008). `src/lib/scout-stato.ts`. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni, valutazioni). | Letta/scritta da `src/lib/scout-store.ts`. `valutazioni` (M39, DD-053): − / + per fondamentale. |
|
||||
| `scout_partite` | Archivio delle partite scoutate concluse (risultato, parziali, azioni). | Letta/scritta da `src/lib/scout-store.ts`. |
|
||||
|
||||
## Votazioni
|
||||
|
||||
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
|
||||
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
||||
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`; voto definitivo per i giocatori (`m35`). |
|
||||
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
|
||||
| Tabella | Scopo | Note |
|
||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- |
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. |
|
||||
| `pagelle_voti` | Voti anonimi ai giocatori (1-10). | Usati per il voto medio. Si scrive solo con `pagelle_chiuse = false`. |
|
||||
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`. |
|
||||
|
||||
## Turni e notifiche
|
||||
|
||||
@@ -122,11 +121,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
||||
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
||||
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||
- Il `CHECK` sul tipo ammette anche `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` restano i tipi dei pulsanti manuali.
|
||||
- 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`)
|
||||
|
||||
@@ -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
|
||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
||||
| `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` |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||
|
||||
## Badge
|
||||
|
||||
|
||||
+2
-2
@@ -12,13 +12,13 @@ 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. |
|
||||
| **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). |
|
||||
| **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). |
|
||||
| **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). |
|
||||
| **Cacche** | Sondaggio pre-partita (`cacche_partita`) usato per statistiche e per il badge segreto `s-cacche`. |
|
||||
| **Palloni** | Turno a rotazione di chi porta e riporta i palloni a partite e allenamenti. Vedi [modules/palloni.md](modules/palloni.md). |
|
||||
| **Scout / Scout Live** | Rilevazione in tempo reale, durante la partita, delle valutazioni − / + di ogni convocato su battuta, ricezione, attacco e muro, per set (DD-053), con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
|
||||
| **Scout / Scout Live** | Rilevazione in tempo reale delle azioni di una partita, con blocco condiviso («Scout occupato da X»). Solo statistiche di squadra, mai classifiche individuali (DD-008). |
|
||||
| **CSI** | Centro Sportivo Italiano, l'ente del campionato. Due usi: il **tesseramento** dei giocatori (manuale, DD-009) e il **collegamento** con classifica e risultati ufficiali ([modules/collegamento-csi.md](modules/collegamento-csi.md)). |
|
||||
| **Stagione CSI** | Si ricava dalle date delle gare, non da un'impostazione (DD-032). |
|
||||
| **Centro notifiche** | Storico in-app (`notifiche_utente`) sull'avatar del profilo, separato dalla push (DD-030). |
|
||||
|
||||
+5
-10
@@ -29,13 +29,11 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
- [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico
|
||||
(caricamento, scadenza, stato, download); la foto tessera è stata tolta (DD-044); lo storico dei certificati
|
||||
resta un'estensione futura
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati ed export CSV; il tracciamento di numero e data
|
||||
di tessera è stato tolto dalla dashboard (DD-048)
|
||||
- [x] Collegamento CSI (stagione 2026/27)
|
||||
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
|
||||
tesserato (numero e data di tessera)
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica (campionato e Coppa)
|
||||
- [x] Risultati campionato
|
||||
- [x] Calendario ufficiale — le gare future del feed CSI compaiono già nella pagina Campionato:
|
||||
a inizio stagione il calendario c'è prima dei risultati
|
||||
- [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
@@ -49,13 +47,10 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
- [x] Notifiche automatiche — turno palloni 3 ore prima, sollecito
|
||||
presenze 24 ore prima (i pulsanti «Avvisa chi è di turno» e «Sollecita» restano; il promemoria a 24 e 3 ore è stato tolto, DD-043) ([specifica](modules/notifiche.md#testi-delle-notifiche), DD-040)
|
||||
|
||||
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
|
||||
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
|
||||
- [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
|
||||
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
- [ ] Database esercizi
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
@@ -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).
|
||||
@@ -1,45 +0,0 @@
|
||||
# DD-051 — Voto MVP definitivo e MVP a pari merito
|
||||
|
||||
**Data:** 26 ottobre 2026
|
||||
**Stato:** Accettata (migration `m35` e `m36`)
|
||||
|
||||
**Contesto**
|
||||
Il voto MVP si poteva cambiare senza limiti e senza storico (`mvp.md`), e una parità in testa non
|
||||
assegnava l'MVP a nessuno finché non arrivavano altri voti. Con voti modificabili la classifica di una
|
||||
partita poteva ribaltarsi dopo che la squadra l'aveva già vista; e una parità, che a fine serata non si
|
||||
risolve più perché nessuno vota ancora, lasciava la partita senza MVP per sempre.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- **Il voto è definitivo.** Il giocatore lo registra una volta sola per partita. L'interfaccia chiede una
|
||||
conferma prima di inviarlo e non offre più «Cambia voto»; la mutazione è un `INSERT` e un secondo voto
|
||||
viene rifiutato dal vincolo `UNIQUE (match_id, votante_id)`. A database (`m35`) il giocatore ha solo la
|
||||
policy di `INSERT`: `UPDATE` e `DELETE` restano agli admin (policy di M11), che possono correggere un
|
||||
voto sbagliato.
|
||||
- **Una parità in testa dà più MVP.** `mvpPartita()` restituisce tutti i più votati; ognuno conta un
|
||||
titolo in `mvpVintiPerGiocatore()`, e dove si mostra un solo nome per partita (Home, Classifica)
|
||||
i nomi sono uniti («Anna e Bruno»). Il quorum di DD-028 (`VOTI_MINIMI_MVP = 2`) resta: sotto due voti
|
||||
totali nessuno è MVP.
|
||||
|
||||
- **Presenza e orario a database (`m36`).** Il voto MVP richiede, oltre a convocati e non-allenatori, che
|
||||
l'evento sia una partita, che votante e votato siano `presente` o `ritardo` e che siano passate 2 ore
|
||||
dall'inizio. Sta in una funzione dedicata, `voto_mvp_consentito()`, perché `evento_permette_voto()` è
|
||||
condivisa con pagelle e badge social, che non hanno queste regole. Gli admin restano esclusi dai
|
||||
vincoli.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Lasciare il voto modificabile entro una scadenza → serve una scadenza che non esiste (`mvp.md`,
|
||||
«Evoluzioni possibili») e un cambio di idea tardivo resta possibile.
|
||||
- Spareggio con un criterio automatico (media pagella, presenze) → introduce regole nascoste che i
|
||||
giocatori non vedono; il pari merito è il risultato più semplice da spiegare.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Un voto dato per errore si corregge solo da un admin.
|
||||
- I voti già dati restano, ma le partite con una parità in testa assegnano ora i titoli: i conteggi MVP
|
||||
dei giocatori possono salire (badge «MVP» e «Uomo tie-break» compresi).
|
||||
- Con tre o più pari merito (per esempio tre voti a tre persone diverse) gli MVP sono tre.
|
||||
|
||||
**Riesame**
|
||||
Se i pari merito risultano troppo frequenti, alzare il quorum (DD-028) o introdurre uno spareggio.
|
||||
@@ -1,44 +0,0 @@
|
||||
# DD-052 — Voto MVP segreto, nome dal database e votazione che si chiude
|
||||
|
||||
**Data:** 26 ottobre 2026
|
||||
**Stato:** Accettata (migration `m37` e `m38`)
|
||||
|
||||
**Contesto**
|
||||
Dopo DD-051 il voto è definitivo e controllato a database, ma restavano tre difetti: chiunque poteva
|
||||
leggere da `mvp_voti` chi aveva votato chi; `votato_nome` lo scriveva il client e poteva non
|
||||
corrispondere a `votato_id` (il titolo andava all'id, il nome mostrato era un altro); e la votazione,
|
||||
una volta aperta, non si chiudeva mai.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- **Voto segreto.** La lettura diretta di `mvp_voti` è del solo votante (il proprio voto) e degli admin.
|
||||
La squadra legge i conteggi dalla vista `mvp_voti_anonimi`, una riga per voto senza `votante_id` e
|
||||
ordinata per candidato (la sequenza di arrivo non rivela nulla). La vista gira con i permessi del
|
||||
proprietario: è ciò che le permette di leggere tutte le righe senza esporre chi ha votato. Non è
|
||||
leggibile senza account.
|
||||
- **Nome dal database.** Un trigger riempie `votato_nome` da `giocatori_squadra` a partire da
|
||||
`votato_id`, ignorando quello inviato (anche se omesso, vuoto o diverso; e anche quando un admin corregge
|
||||
un voto). Se l'id non è in rosa resta il nome ricevuto. Lo stesso trigger vale per i badge social
|
||||
(`m38`), che avevano lo stesso difetto; le pagelle non hanno un nome del votato.
|
||||
- **Chiusura.** Il voto si apre 2 ore dopo l'inizio della partita e si chiude 24 ore dopo l'apertura
|
||||
(cioè 26 ore dopo l'inizio). A database lo impone `voto_mvp_consentito()`; nell'app `votoMvpChiuso()`.
|
||||
Gli admin restano fuori dal vincolo.
|
||||
- **Tutte le partite.** Il voto vale per ogni evento di tipo partita, amichevoli comprese (invariato).
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Filtrare i voti solo nell'app → chi scrive su PostgREST li leggerebbe comunque.
|
||||
- Togliere la lettura anche agli admin → non potrebbero più correggere un voto sbagliato; restano gli
|
||||
unici a poter leggere la tabella, e l'app non mostra a nessuno chi ha votato chi.
|
||||
- Chiudere 24 ore dopo l'inizio della partita (22 ore di voto) → si è scelto 24 ore piene dall'apertura.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Le partite già chiuse da più di 26 ore non si votano più: chi non aveva votato perde la possibilità.
|
||||
I voti già dati restano e contano.
|
||||
- I conteggi restano visibili durante la votazione: chi vota può vedere chi è in testa.
|
||||
- Un admin che legge la tabella dal database vede ancora chi ha votato chi.
|
||||
|
||||
**Riesame**
|
||||
Se si vuole un voto davvero cieco, nascondere i risultati fino alla chiusura (la chiusura a 24 ore lo
|
||||
rende possibile).
|
||||
@@ -1,40 +0,0 @@
|
||||
# DD-053 — Valutazioni − / + dei fondamentali nello Scout Live
|
||||
|
||||
**Data:** 2 novembre 2026
|
||||
**Stato:** Accettata (migration `m39`)
|
||||
|
||||
**Contesto**
|
||||
Lo scout registrava solo ciò che fa punto o lo toglie (attacco, ace, muro, errore). Mancava un
|
||||
giudizio sul resto del gioco — battuta, ricezione, attacco, muro — che non cambia il punteggio.
|
||||
|
||||
**Decisione**
|
||||
|
||||
- Per ogni convocato, un **−** o un **+** su battuta, ricezione, attacco e muro, **per set**.
|
||||
- Sono valutazioni, non azioni: lista `valutazioni` separata da `azioni`, così non entrano nel
|
||||
punteggio, nei parziali né nei totali P/A/M/E (`totaliPerGiocatore`, badge, classifiche: DD-008).
|
||||
- Ogni tocco è una riga (giocatore, fondamentale, segno, set, istante): permette l'annulla e il
|
||||
conteggio per set senza strutture da tenere allineate.
|
||||
- Stato in corso dentro `scout_live.stato` (jsonb, nessuna modifica); partita archiviata nella
|
||||
nuova colonna `scout_partite.valutazioni jsonb NOT NULL DEFAULT '[]'` (`m39`, solo additiva).
|
||||
|
||||
- Nella schermata dello scout, su richiesta, sono stati tolti i tasti delle azioni, il tabellone del
|
||||
punteggio e il blocco avversario/campo: lo scout è fatto solo di valutazioni. Non c'è più una
|
||||
«Chiudi set» e «Fine partita» funzionano anche senza punti: il set chiuso vale 0-0 e la partita
|
||||
archiviata di sole valutazioni **non è un risultato** (`haPunteggio()`), quindi non compare in
|
||||
Home, Classifica né al posto del referto CSI.
|
||||
- Il salvataggio dello stato in corso è immediato a ogni tocco, con avviso se fallisce.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Nuovi `AzioneTipo` dentro `azioni` → ogni consumatore (punteggio, totali, CSV) andrebbe filtrato
|
||||
per non contarli come punti.
|
||||
- Solo contatori aggregati per giocatore/set → niente annulla preciso, né ordine cronologico.
|
||||
- Tabella dedicata → per un dato che nasce e muore con la partita archiviata è più schema del
|
||||
necessario.
|
||||
|
||||
**Conseguenze**
|
||||
Le partite già archiviate hanno `valutazioni = []`. Le valutazioni sono visibili solo nel CSV
|
||||
admin; sono dato di squadra a uso tecnico, non entrano in classifiche individuali.
|
||||
|
||||
**Riesame**
|
||||
Se servirà confrontare le valutazioni tra partite o mostrarle ai giocatori.
|
||||
+46
-53
@@ -46,59 +46,52 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](DD-005.md) | Mobile-first |
|
||||
| [DD-006](DD-006.md) | AI solo se utile |
|
||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||
| [DD-022](DD-022.md) | App solo chiara |
|
||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
||||
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
|
||||
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
|
||||
| [DD-053](DD-053.md) | Valutazioni − / + dei fondamentali nello Scout Live |
|
||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||
| [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 |
|
||||
| ID | Titolo |
|
||||
| ------------------- | ------------------------------------------- |
|
||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](DD-005.md) | Mobile-first |
|
||||
| [DD-006](DD-006.md) | AI solo se utile |
|
||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||
| [DD-022](DD-022.md) | App solo chiara |
|
||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||
| [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**
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ nessuna schermata:
|
||||
| Dove | Cosa si nasconde all'allenatore |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche 💩» |
|
||||
| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» |
|
||||
| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) |
|
||||
| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) |
|
||||
| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) |
|
||||
@@ -132,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 . |
|
||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
||||
| 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. |
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
condizioni esatte, è una scelta deliberata per la documentazione tecnica, non una fuga di
|
||||
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 |
|
||||
| `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-conferme` | Risposta lampo | conferme di presenza consecutive date entro 24h dalla convocazione (`g.serieConferme`) | 3 / 8 / 15 |
|
||||
|
||||
### 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 è
|
||||
`grado !== null`, non il suo valore, ed è così che li legge `badgeSegretiSbloccati()`.
|
||||
|
||||
| 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-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 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`) |
|
||||
| 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-mai-forfait` | Mai un forfait | almeno 10 conferme rapide consecutive **e** almeno 15 presenze (`g.serieConferme >= 10 && g.presenze >= 15`) |
|
||||
| `s-infermeria` | Cliente VIP dell'Infermeria | almeno 3 eventi saltati per infortunio (`g.infortuni >= 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)
|
||||
|
||||
@@ -147,22 +149,24 @@ dicono i file, non questa pagina: leggili prima di cambiare una soglia.
|
||||
|
||||
### Riepilogo per badge
|
||||
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------------ | ----------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 6 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 7 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 8 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 9 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 10 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 11 | `fairplay` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `badge-social`) |
|
||||
| 12 | `meme` | social | ✅ | ✅ (`badge-social`) |
|
||||
| 13 | `cuore` | social | ✅ | ✅ (autovoto, `badge-social`) |
|
||||
| # | id | tipo | test unit | test integration |
|
||||
| --- | ------------------- | ------- | ----------------------------- | --------------------------------------------- |
|
||||
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
|
||||
| 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
|
||||
| 3 | `palloni` | normale | ✅ | ✅ (`scritture`, `palloni-badge`) |
|
||||
| 4 | `presenze` | normale | ✅ | ✅ (`obiettivi`, `presenze-badge`) |
|
||||
| 5 | `serie-allenamenti` | normale | ✅ | ✅ (`serie-allenamenti-badge`) |
|
||||
| 6 | `serie-conferme` | normale | ✅ (vedi Limiti noti) | ✅ (`serie-conferme-badge`) |
|
||||
| 7 | `s-tiebreak` | segreto | ✅ (soglia minima voti) | ✅ (`s-tiebreak-badge`) |
|
||||
| 8 | `s-mai-forfait` | segreto | ✅ | ✅ (`s-mai-forfait-badge`) |
|
||||
| 9 | `s-infermeria` | segreto | ✅ | ✅ (`s-infermeria-badge`) |
|
||||
| 10 | `s-ritardi` | segreto | ✅ | ✅ (`s-ritardi-badge`) |
|
||||
| 11 | `s-cacche` | segreto | ✅ | ✅ (`s-cacche-badge`) |
|
||||
| 12 | `affidabile` | social | ✅ | ✅ (`scritture`, `permessi`, `badge-social`) |
|
||||
| 13 | `spirito` | social | ✅ (meccanismo generico) | ✅ (meccanismo generico, `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
|
||||
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: il badge «Sempre in palestra» si muove solo se cambia la serie
|
||||
Allenamenti. Il badge «Risposta lampo» e il segreto «Mai un forfait», basati sulla serie «Conferme 24h», sono stati
|
||||
rimossi il 2026-10-06.
|
||||
- **Dipendenza dal modulo [Serie](serie-presenze.md)**: i badge "Sempre in palestra",
|
||||
"Risposta lampo" e il segreto "Mai un forfait" si muovono solo se cambiano le serie. Le
|
||||
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à
|
||||
"ottenuto" può sparire o apparire retroattivamente.
|
||||
- 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
|
||||
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
|
||||
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
|
||||
@@ -232,7 +238,7 @@ ancora correggere un voto anche fuori convocazione o dopo la chiusura.
|
||||
## Evoluzioni possibili
|
||||
|
||||
- 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.
|
||||
- Rimuovere `badgeSbloccati()` (codice morto) o documentarne lo scopo.
|
||||
- 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:
|
||||
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
|
||||
di evento, i giorni con più eventi si dividono lo spazio. In fondo alla pagina la sezione
|
||||
«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.
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||
(`usePuoGestireEventi()`, [DD-034](../decisions/DD-034.md)):
|
||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||
@@ -53,13 +49,6 @@ calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le
|
||||
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||
`eventi_app`.
|
||||
|
||||
**Link Google Maps.** `eventi_app.link_maps` (M34, facoltativo) si imposta dal form di `/eventi`
|
||||
sotto «Luogo»: si incolla il link di «Condividi» di Google Maps. `linkMapsValido()` in `eventi.ts`
|
||||
accetta solo URL https di Google Maps (`maps.app.goo.gl`, `goo.gl/maps`, `google.*/maps`,
|
||||
`maps.google.*`) e lo stesso criterio è un CHECK sul database. Le tre schede di dettaglio mostrano
|
||||
«Apri su Maps» (nuova scheda) solo se il link c'è; non c'è un link generato dal testo del
|
||||
luogo, e né la card né le notifiche lo mostrano.
|
||||
|
||||
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Modulo — Collegamento CSI
|
||||
|
||||
**Stato:** implementato (stagione 2026/27)
|
||||
**Stato:** implementato (stagione 2025/26)
|
||||
**Route interessate:** `/classifica` (classifica e storico), `/partita/$id` e `/partita-csi/$id`
|
||||
(dettaglio di una gara: formazioni e scontri diretti)
|
||||
|
||||
@@ -28,50 +28,52 @@ verificato leggendo `assets/js/project.js` e `assets/js/team.js`, referenziati i
|
||||
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
|
||||
un `project_id`), nessun codice le chiama direttamente.
|
||||
|
||||
### Identificativi (stagione 2026/27)
|
||||
### Identificativi (stagione 2025/26)
|
||||
|
||||
| Cosa | Valore |
|
||||
| --------------------- | -------------------------------------- |
|
||||
| Campionato | PVM - Campionato Open Misto Eccellenza |
|
||||
| `project_id` (girone) | `933` |
|
||||
| Coppa | nessuna: la squadra non è iscritta |
|
||||
| `project_id` (coppa) | `null` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` |
|
||||
| `team_id` | `3878` |
|
||||
| Girone | B (7 squadre) |
|
||||
| `project_id` (girone) | `767` |
|
||||
| Coppa | PVM Coppa CSI Misto Silver |
|
||||
| `project_id` (coppa) | `848` |
|
||||
| Squadra sul portale | `C.R.A.P. Volley` (con i punti) |
|
||||
| `team_id` | `3359` |
|
||||
| Girone | B |
|
||||
|
||||
`project_id` (933), `CSI_COPPA_PROJECT_ID` (`null`) e `team_id` (3878) sono costanti in
|
||||
`project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in
|
||||
`src/lib/csi-core.ts`.
|
||||
|
||||
**A ogni stagione il CSI crea una nuova squadra, con un nuovo `team_id`.** Il 2025/26 era
|
||||
`3359` (campionato `767`, Coppa `848`); verificato il 29/10/2026 che `getEventsByTeamId.php`
|
||||
per `3878` restituisce solo le 12 gare 2026/27 (4/11/2026 – 11/3/2027), nessuna giocata, e per
|
||||
`3359` solo quelle del 2025/26. Cambiando `team_id` lo storico della stagione scorsa sparisce
|
||||
da `/classifica`: è voluto, si lascia perdere la stagione passata. A inizio stagione la
|
||||
classifica è tutta a zero (giocate 0): non è un errore di parsing.
|
||||
**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora
|
||||
creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono
|
||||
ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica`
|
||||
mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel
|
||||
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
|
||||
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
|
||||
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
|
||||
a 2026/27 ([DD-032](../decisions/DD-032.md)).
|
||||
|
||||
Per ritrovare gli id a ogni cambio stagione: aprire `team_details.php?team_id=<nuovo id>` (il
|
||||
`team_id` si legge dal link della squadra nella pagina del girone) oppure
|
||||
`components/team-main.php?team_id=<id>`, che contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta (oggi solo
|
||||
`933`). Se il CSI crea una Coppa e la squadra vi è iscritta, compare qui: si mette il suo id in
|
||||
`CSI_COPPA_PROJECT_ID` e la sezione ricompare in `/classifica`.
|
||||
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
|
||||
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
|
||||
`league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta —
|
||||
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
|
||||
`project_id=848` (Coppa) sia `project_id=767` (Campionato). Non serve aprire
|
||||
`team_details.php` nel browser, questo componente basta.
|
||||
|
||||
### Endpoint usati dall'app
|
||||
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| -------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `components/project-sheets.php?project_id=933` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=933` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=<Coppa>` | HTML | Classifica del girone di Coppa, solo se `CSI_COPPA_PROJECT_ID` non è `null` (vedi limite 4) | `league_details.php?project_id=<Coppa>` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3878` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3878` (tab "Calendario", `team-calendar.php`) |
|
||||
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|
||||
| ------------------------------------------------ | ------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi di campionato | `league_details.php?project_id=767` (tab "Classifica") |
|
||||
| `components/project-sheets.php?project_id=848` | HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | `league_details.php?project_id=848` (tab "Classifica") |
|
||||
| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra | `team_details.php?team_id=3359` (tab "Calendario", `team-calendar.php`) |
|
||||
|
||||
**Formato di `project-sheets.php`** — tabella HTML per girone (una per `<table>`,
|
||||
`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra).
|
||||
Colonne per `<td>` (0-indicizzate): `0` Pos · `1` Squadra (nome + logo + link a
|
||||
`team_details.php?team_id=…`) · `2` Punti · `3` Partite giocate · `4` Vinte · `5` Perse ·
|
||||
`6`-`7` Tie-break vinti/persi (non lette) · `8` Set fatti · `9` Set subiti · poi punti
|
||||
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per il campionato
|
||||
(`project_id=933`) e per la fase a gironi della Coppa, quando c'è: `parseClassifica()` è
|
||||
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per `project_id=767`
|
||||
(campionato) e `project_id=848` (fase a gironi della Coppa): `parseClassifica()` è
|
||||
condivisa, nessun parser dedicato per la Coppa.
|
||||
|
||||
**Formato di `getEventsByTeamId.php`** — array JSON, un oggetto per gara (girone **e**
|
||||
@@ -230,8 +232,7 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
tabella/lista, sia per classifica/partite sia per formazioni/precedenti.
|
||||
- **Fallback**: se il CSI non risponde, l'endpoint `/api/public/csi` restituisce l'ultimo
|
||||
dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati
|
||||
ricadono sulle partite dello Scout Live che hanno un punteggio (`useScoutMatches()` filtrato con
|
||||
`haPunteggio()`: quelle di sole valutazioni non sono un risultato).
|
||||
ricadono sulle partite dello Scout Live locale (`useScoutMatches()`).
|
||||
`/api/public/csi-partita/$id` non ha questo fallback sulla prima chiamata per una gara mai
|
||||
vista (vedi sopra): fallisce con `503`, e la UI (`DettaglioCsiEsteso`) semplicemente non
|
||||
mostra la sezione formazioni/scontri diretti, senza rompere il resto della pagina.
|
||||
@@ -244,12 +245,11 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
1. **La classifica si legge da HTML.** Se il portale cambia la struttura della tabella il
|
||||
parsing restituisce un array vuoto: `/classifica` non si rompe, ma mostra "Classifica non
|
||||
ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono
|
||||
sulle partite dello Scout Live con punteggio, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||
sulle partite dello Scout Live locale, non su dati demo — non esistono più in `crapp-data.ts`.
|
||||
Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing.
|
||||
2. **`project_id` e `team_id` sono legati alla stagione.** Per il 2027/28 serviranno due nuovi
|
||||
id (vedi "Sorgente dati" sopra per come ritrovarli): vanno aggiornati a mano in
|
||||
`csi-core.ts`, e lo storico della stagione precedente sparisce (DD-032 non lo conserva).
|
||||
L'etichetta «Stagione 2026/27» di `/classifica` invece non va toccata: il portale non
|
||||
2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi
|
||||
"Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`.
|
||||
L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non
|
||||
espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano
|
||||
dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`.
|
||||
3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra
|
||||
@@ -258,21 +258,20 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio
|
||||
dati in una tabella Supabase riempita da un job cron (stesso pattern di
|
||||
`promemoria-palloni`).
|
||||
4. **Le partite includono sia il girone di campionato sia la Coppa, mescolate.**
|
||||
`getEventsByTeamId.php?team_id=3878` è per squadra, non per competizione (vedi tabella
|
||||
`getEventsByTeamId.php?team_id=3359` è per squadra, non per competizione (vedi tabella
|
||||
endpoint sopra): risponde con tutte le gare di `C.R.A.P. Volley`. Il campo `project`
|
||||
distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa
|
||||
per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico
|
||||
partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il
|
||||
filtro va aggiunto su `evento.project` in `partiteDaEventi()`.
|
||||
**La classifica della Coppa, invece, è mostrata quando c'è** (sopra quella del girone in
|
||||
`/classifica`; nel 2026/27 `CSI_COPPA_PROJECT_ID` è `null` e la sezione non compare). Nel
|
||||
2025/26 era `project_id=848`: `project-sheets.php` ha la stessa
|
||||
struttura a tabella-per-girone del campionato, quindi `parseClassifica()` funziona
|
||||
**La classifica della Coppa, invece, è mostrata** (sopra quella del girone in
|
||||
`/classifica`): `project-sheets.php?project_id=848` (`CSI_COPPA_PROJECT_ID`) ha la stessa
|
||||
struttura a tabella-per-girone di `project_id=767`, quindi `parseClassifica()` funziona
|
||||
invariata — nessun parser dedicato. Resta un limite: quella pagina copre **solo la fase a
|
||||
gironi**. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola
|
||||
andata seguiti da una finale secca tra le due vincenti, disputata in un `project_id`
|
||||
figlio separato generato a fine fase a gironi (verificato con `curl` diretto:
|
||||
`project-main.php?project_id=848` (2025/26) descrive il regolamento — "due gironi sola andata, le
|
||||
`project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le
|
||||
due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra
|
||||
sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il
|
||||
tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in
|
||||
|
||||
+19
-38
@@ -2,7 +2,6 @@
|
||||
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
||||
**Test:** `test/unit/mvp-voti.test.ts`, `test/integration/permessi.test.ts` (regole del voto), `mvp-badge`, `s-tiebreak-badge`
|
||||
|
||||
---
|
||||
|
||||
@@ -16,10 +15,7 @@ calcolato a runtime.
|
||||
## Dati
|
||||
|
||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||
partita, **definitivo** (DD-051) e **segreto** (DD-052): la tabella la legge solo chi ha votato (il
|
||||
proprio voto) e gli admin; la squadra legge i conteggi dalla vista `mvp_voti_anonimi` (`match_id`,
|
||||
`votato_id`, `votato_nome`, senza `votante_id`). `votato_nome` lo riempie un trigger dalla rosa a
|
||||
partire da `votato_id`: il nome inviato dal client è ignorato.
|
||||
partita, sovrascrivibile.
|
||||
|
||||
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
|
||||
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
|
||||
@@ -31,59 +27,44 @@ restano nel database ma non vengono più letti da nessuna schermata).
|
||||
|
||||
- Il pannello sta in `partita.$id.tsx` in una sezione sua, sempre presente: `votoMvpAperto()`
|
||||
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
|
||||
quando aprirà; `votoMvpChiuso()` lo chiude `DURATA_VOTO_MVP_ORE` (24) ore dopo l'apertura
|
||||
(DD-052). A votazione chiusa il pannello mostra «Votazione chiusa» e i risultati, senza il
|
||||
pulsante «Vota». Nessun legame con il risultato caricato.
|
||||
quando aprirà. Nessun legame con il risultato caricato.
|
||||
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
|
||||
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
|
||||
- `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
|
||||
tabella solo i voti dell'utente (gli unici che la policy gli lascia), per «Hai votato X».
|
||||
- `useVotaMvp()` fa un `INSERT`: il voto è definitivo. Il pannello chiede di scegliere un nome e di
|
||||
confermarlo, poi mostra «Hai votato X: il voto non si può cambiare». A database (`m35`, DD-051) il
|
||||
giocatore ha solo la policy di `INSERT`: modificare o cancellare un voto è possibile solo agli admin.
|
||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
||||
limiti, senza storico.
|
||||
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
|
||||
`mvp_no_autovoto` (migration `m12_niente_autovoto`) rifiuta la riga anche a chi scrive
|
||||
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||
- `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
|
||||
(DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
|
||||
- `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
|
||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
||||
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||
incoronare nessuno, nemmeno senza concorrenza.
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
|
||||
(anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||
nel profilo e in home.
|
||||
|
||||
---
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Il voto si chiude 24 ore dopo l'apertura (DD-052). Le partite più vecchie sono quindi chiuse:
|
||||
chi non aveva votato non può più farlo. Gli admin possono ancora scrivere un voto dal database.
|
||||
- Gli admin leggono la tabella `mvp_voti` (gli serve per correggere un voto), quindi a livello di
|
||||
database vedono chi ha votato chi; l'app non lo mostra a nessuno.
|
||||
- I conteggi sono visibili anche durante la votazione. Nascondere i risultati fino alla chiusura è una
|
||||
possibile evoluzione.
|
||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
||||
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
|
||||
vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
|
||||
Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
|
||||
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||
= tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
|
||||
controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
|
||||
sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
|
||||
vale mezzanotte). Sono le stesse
|
||||
regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
|
||||
e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
|
||||
(`m37`); un test unitario li tiene allineati. Gli
|
||||
admin restano fuori da questi vincoli (possono correggere un voto). La presenza si controlla solo
|
||||
al momento del voto: se qualcuno cambia risposta dopo, il voto già dato resta.
|
||||
- Sotto il quorum minimo di voti nessun MVP viene assegnato per quella partita. Una parità in testa,
|
||||
invece, assegna l'MVP a tutti i pari merito.
|
||||
- Un voto sbagliato si corregge solo da un admin (dal database): non c'è un'interfaccia per farlo.
|
||||
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
||||
partita.
|
||||
|
||||
---
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Nascondere i risultati finché la votazione non è chiusa.
|
||||
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
|
||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
||||
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
||||
|
||||
## Quali notifiche e a chi
|
||||
|
||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), 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
|
||||
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
|
||||
|
||||
+32
-46
@@ -15,7 +15,7 @@ Tenere aggiornati i giocatori senza che debbano aprire l'app, con due meccanismi
|
||||
indipendenti:
|
||||
|
||||
- **Push VAPID** — arrivano anche ad app chiusa (messaggi dello staff, turno palloni, sollecito
|
||||
presenze, compleanni, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
presenze, sondaggio pre-partita: elenco completo nel [catalogo](#catalogo-delle-notifiche)).
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
@@ -34,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
|
||||
quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
||||
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
||||
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
||||
| 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ì |
|
||||
| # | Notifica | Cosa la fa partire | Destinatari | Push | In-app | Email |
|
||||
| --- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------ | --------------- |
|
||||
| 1 | ~~Promemoria a 24 ore~~ — **rimosso** | Non parte più: i job `pg_cron` sono stati tolti dalla migration `m28` (DD-043). Restano le notifiche già generate (tipo `evento_promemoria_24h`). | — | — | — | — |
|
||||
| 2 | ~~Promemoria a 3 ore~~ — **rimosso** | Come la riga 1 (tipo `evento_promemoria_3h`). | — | — | — | — |
|
||||
| 3 | **Messaggio dello staff** — titolo fisso «Messaggio dallo staff», corpo il testo scritto (max 300 caratteri) | Un admin, dalla tab «Notifiche» della dashboard: «Invia messaggio a tutti» o il pulsante della riga di un giocatore. Route `notifica-personalizzata`, tipo `admin`. | Push: tutti i dispositivi iscritti, o quelli del giocatore scelto. In-app ed email: tutta la rosa attiva (allenatori compresi, anche senza dispositivi iscritti), o il giocatore scelto. | sì | sì | sì |
|
||||
| 4 | **Turno palloni** — «Turno palloni: incarico assegnato» / «Turno palloni: riconsegna» | **Automatica**, **una sola volta, 3 ore prima** dell'evento (DD-040, DD-042): job `pg_cron` ogni 15 minuti, solo per allenamenti e partite con un incaricato confermato; tipo `turno_palloni_3h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m27`), push dal worker. Se l'incaricato cambia, chi aveva già ricevuto un avviso riceve una volta «Turno palloni: incarico revocato» (tipo `turno_palloni_revocato`). **Manuale**, in più: un admin, con il pulsante nella pagina dell'evento; route `promemoria-palloni`, tipo `turno_palloni`, push dalla route. | L'incaricato del turno di quell'evento e, se è un'altra persona, chi aveva i palloni all'evento precedente. Se per l'evento non c'è un incaricato non parte nulla. | sì | sì | **no** (DD-040) |
|
||||
| 5 | **Sollecito presenze** — «Conferma di partecipazione richiesta: _titolo_» | **Automatica**, **una sola volta, 24 ore prima** dell'evento (DD-040, DD-045): per gli eventi che iniziano entro 24 ore, dallo stesso job; tipo `sollecita_presenze_24h` (`_12h` e `_6h` esistono solo nelle notifiche generate prima di `m30`), push dal worker. **Manuale**, in più: un admin o un allenatore con il pulsante nella pagina dell'evento; route `sollecita-presenze`, tipo `sollecita_presenze`, push dalla route. | Automatica e manuale: i destinatari dell'evento (convocati, o tutta la rosa attiva se `convocati` è vuoto; mai gli allenatori) che in quel momento non hanno risposto o hanno risposto «forse». | sì | sì | sì |
|
||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||
|
||||
Le notifiche generate dal server (righe 1-6, 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
|
||||
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
|
||||
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
|
||||
disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `GG/MM/AAAA`.
|
||||
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
|
||||
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
|
||||
| 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 |
|
||||
| # | Notifica | Titolo | Corpo |
|
||||
| --- | --------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1-2 | Promemoria 24 e 3 ore (rimosso, DD-043) | `Promemoria evento di domani: <titolo evento>`, oppure `…di oggi: <titolo evento>` | `Data: domani, <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>` — «domani» se la data dell'evento è quella di domani, «oggi» altrimenti (fuso `Europe/Rome`, vale per entrambi i promemoria) |
|
||||
| 3 | Messaggio dello staff | `Messaggio dallo staff` | Il testo scritto dall'admin, max 300 caratteri. |
|
||||
| 4a | Turno palloni, all'incaricato | `Turno palloni: incarico assegnato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: custodia dei palloni al termine dell'evento`<br>`Riconsegna: <GG/MM/AAAA dell'evento successivo>` — senza evento successivo la riga «Riconsegna» manca |
|
||||
| 4b | Turno palloni, a chi li aveva | `Turno palloni: riconsegna` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: riconsegna dei palloni in custodia dal turno precedente` |
|
||||
| 4c | Turno palloni, revoca | `Turno palloni: incarico revocato` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Incarico: non più a tuo carico per questo evento` — a chi aveva ricevuto un avviso (4a o 4b) e non è più destinatario; una sola volta |
|
||||
| 5 | Sollecito presenze | `Conferma di partecipazione richiesta: <titolo>` | `Data: <GG/MM/AAAA>`<br>`Ora: <ora>`<br>`Luogo: <luogo>`<br>`Richiesta di: <Nome>` (solo nel sollecito manuale, e solo se il nome c'è)<br>`Risposta attuale: nessuna` (oppure `forse`)<br>`Azione: indicare presente, assente o in ritardo` — stesso testo a 24, 12 e 6 ore |
|
||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||
|
||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||
@@ -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
|
||||
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.
|
||||
- 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.
|
||||
- La registrazione o il collegamento di un nuovo giocatore.
|
||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||
- Un compleanno non ancora arrivato: l'avviso parte solo il giorno stesso (DD-047).
|
||||
|
||||
### Comportamenti da conoscere
|
||||
|
||||
@@ -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
|
||||
sollecito presenze (il promemoria è stato tolto, DD-043), più quelle dei pulsanti manuali (sollecito, sondaggio). Il turno palloni
|
||||
(avvisi automatici, revoca e pulsante) non manda email: restano la push e l'alert in app.
|
||||
- **Il compleanno parte una volta al giorno, dalle 8:00 (fuso `Europe/Rome`)** (DD-047): il job gira ogni ora e
|
||||
agisce solo dalle 8; il registro `compleanni_notificati` (per destinatario e giorno) impedisce il doppione anche se
|
||||
il giocatore elimina la notifica. Chi non ha la data di nascita o non è attivo non è né festeggiato né destinatario.
|
||||
Un nuovo membro, o chi inserisce la nascita, dopo che gli avvisi del giorno sono partiti non genera un secondo giro.
|
||||
- **29 febbraio: per ora non gestito** (DD-047). Chi è nato il 29 febbraio è avvisato solo negli anni bisestili: negli
|
||||
altri non parte nulla, né il 28 febbraio né il 1° marzo. Limite noto e accettato.
|
||||
- **Non esistono preferenze per tipo di notifica:** un solo interruttore per la push («Notifiche»)
|
||||
e uno per le email («Email»). Il centro notifiche in-app non si può spegnere.
|
||||
|
||||
### Test che verificano il catalogo
|
||||
|
||||
| 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) |
|
||||
| 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` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
||||
| 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) |
|
||||
| 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`) |
|
||||
| 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) |
|
||||
| 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` |
|
||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||
| 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
|
||||
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,
|
||||
copertura test completa (unit + integration) su tutti e 10 gli obiettivi.
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`),
|
||||
`src/components/crapp/ObiettivoDrawer.tsx` (card di dettaglio)
|
||||
**File principali:** `src/lib/obiettivi.ts`, `src/lib/rosa.ts` (`useObiettivi()`)
|
||||
|
||||
---
|
||||
|
||||
@@ -38,27 +37,22 @@ corrente dinamico (vedi sotto).
|
||||
|
||||
## Obiettivi definiti
|
||||
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ---------------------------------- |
|
||||
| `o1` | 90% presenze del mese | presente/ritardo su partite+allenamenti del mese corrente; posti = convocati dell'evento, tutta la rosa se vuoto (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | Presenze collettive | % di presenze (presente/ritardo) su tutti gli allenamenti e le partite definiti, passati e futuri; posti = convocati dell'evento (tutta la rosa se vuoto) | 90 | `eventi_app` + `risposte_presenze` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente la cui data e ora sono già passate (dinamico) | 1 | `eventi_app` |
|
||||
| id | Obiettivo | Calcolo | Target | Fonte |
|
||||
| ----- | ---------------------------------- | ----------------------------------------------------------------------------- | --------------------- | ----------------------------- |
|
||||
| `o1` | 90% presenze del mese | risposte presente/ritardo su partite+allenamenti del mese corrente (dinamico) | 90% | `risposte_presenze` |
|
||||
| `o2` | Tutti rispondono alle convocazioni | risposte totali / eventi possibili (esclusi i compleanni) | 90% | `risposte_presenze` |
|
||||
| `o7` | 250 presenze complessive | somma presenze di tutta la rosa, stagione intera | 250 | aggregato da `useRosa()` |
|
||||
| `o12` | Media pagelle da 7.5 | media di tutti i voti, arrotondata a una cifra decimale | 7.5 | `pagelle_voti` |
|
||||
| `o13` | 200 pagelle compilate | conteggio voti | 200 | `pagelle_voti` |
|
||||
| `o11` | Continuità di squadra | giocatori con ≥3 allenamenti consecutivi | 12 (min. per un 6vs6) | `serieAllenamenti` |
|
||||
| `o3` | Prima vittoria del campionato | `min(vittorie, 1)` | 1 | JSON partite CSI (vedi sotto) |
|
||||
| `o4` | 5 vittorie in campionato | `min(vittorie, 5)` | 5 | JSON partite CSI |
|
||||
| `o5` | 10 vittorie in campionato | `min(vittorie, 10)` | 10 | JSON partite CSI |
|
||||
| `o6` | 1 evento di squadra al mese | eventi di tipo "evento" nel mese corrente (dinamico) | 1 | `eventi_app` |
|
||||
|
||||
Mostrati in `squadra.tsx` (elenco completo con barra di progresso) e in `index.tsx` (home: il
|
||||
primo obiettivo non completato).
|
||||
Ogni obiettivo è **cliccabile**: apre la card di dettaglio (`ObiettivoDrawer`, stesso drawer dei
|
||||
badge) con stato attuale, come si calcola, cosa conta e cosa no, quando vale, da dove arrivano i
|
||||
dati e un esempio con numeri. Per le due percentuali di presenze (`o1`, `o7`) la card distingue «Presenza persa» (assente, forse, infortunato, nessuna risposta: il posto resta vuoto) da «Non entra nel calcolo» (cene, eventi di squadra, compleanni). I testi stanno nel campo `dettaglio` di ogni obiettivo in
|
||||
`obiettivi.ts`: chi cambia il calcolo o il target di un obiettivo aggiorna anche il suo
|
||||
`dettaglio`, altrimenti la card mente. Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (200 pagelle, 7.5 di media, 1/5/10
|
||||
primo obiettivo non completato). Un obiettivo che supera il 90% genera anche una notifica
|
||||
smart (`notifiche-smart.ts`). I target fissi (250 presenze, 200 pagelle, 7.5 di media, 1/5/10
|
||||
vittorie) sono scelte editoriali da rivedere a mano a ogni stagione — nessuna configurazione o
|
||||
UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità di squadra"
|
||||
(vedi sotto): il suo target ha un significato specifico, non va scalato come gli altri.
|
||||
@@ -71,22 +65,7 @@ UI per farlo, si cambia il numero in `obiettivi.ts`. Fa eccezione "Continuità d
|
||||
automaticamente a ogni cambio mese: il mese di riferimento è calcolato dalla data corrente
|
||||
(fuso Europe/Rome, `meseCorrente(oggi)`), non più una costante fissa. Per `o1`, titolo
|
||||
("90% di presenze ad agosto" / "a settembre" / ...) e scadenza (ultimo giorno del mese)
|
||||
seguono di conseguenza. I posti di ogni partita e allenamento sono i convocati (tutta la rosa se non ce
|
||||
ne sono): chi non è convocato non pesa, mentre assente, forse, infortunato e nessuna risposta sono
|
||||
presenze perse.
|
||||
|
||||
`o6` si completa quando **arriva l'ora dell'evento**, non quando lo si definisce: conta solo
|
||||
un evento di tipo "evento" del mese corrente il cui `data` + `ora` (fuso Europe/Rome,
|
||||
`adessoRoma(oggi)`) è già passato; senza ora vale `00:00`. Un evento futuro lascia l'obiettivo a
|
||||
0/1 finché non scatta l'ora. Il valore si ricalcola al render: con l'app già aperta il
|
||||
passaggio a completato compare al refresh successivo.
|
||||
|
||||
`o7` ("Presenze collettive") invece **non** si azzera: copre tutta la stagione, cioè tutti gli
|
||||
allenamenti e le partite in calendario, passati e futuri (le risposte sugli eventi futuri sono
|
||||
conferme). Differisce da `o1` solo per il periodo (stagione, non mese): anche in `o1` i posti sono i
|
||||
convocati di ciascun evento, così chi non è convocato non pesa; con la stessa funzione
|
||||
`percentualePresenze()`. Non c'è un confine di stagione esplicito: conta ciò
|
||||
che c'è in `eventi_app`.
|
||||
seguono di conseguenza.
|
||||
|
||||
`o2` ("Tutti rispondono alle convocazioni") non si azzera — aggrega su tutti gli eventi in
|
||||
programma, non solo quelli del mese corrente — ma la sua `scadenza` mostrata in interfaccia è
|
||||
@@ -165,29 +144,21 @@ Dettagli completi (endpoint, identificativi di stagione, altri limiti del colleg
|
||||
Tutti e 10 gli obiettivi hanno unit test **e** integration test end-to-end (dati scritti/letti
|
||||
da un backend reale, non solo funzione pura con contesto costruito a mano).
|
||||
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale, o1 anche con i convocati) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
| Obiettivi | Unit test | Integration test |
|
||||
| ---------- | ----------------------------- | -------------------------------------------------------- |
|
||||
| o1, o2, o6 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o7 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o11 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o12, o13 | `test/unit/obiettivi.test.ts` | `test/integration/obiettivi.test.ts` (Supabase locale) |
|
||||
| o3, o4, o5 | `test/unit/obiettivi.test.ts` | `test/integration/api.test.ts` (CSI reale in produzione) |
|
||||
|
||||
- **Presenze (o1, o7), casi limite** (`test/unit/obiettivi.test.ts`): solo partite e allenamenti
|
||||
contano, assente/forse/infortunato/nessuna risposta sono posti vuoti, il non convocato non pesa,
|
||||
confine di mese nel fuso Europe/Rome, convocato fuori rosa, rosa vuota, arrotondamento e soglia
|
||||
del 90%. Verificato anche con mutazioni (posti = tutta la rosa; eventi di squadra contati): il test
|
||||
diventa rosso.
|
||||
- **Card di dettaglio** (`test/unit/obiettivi-dettaglio.test.ts`): ogni obiettivo ha tutti i testi,
|
||||
«Presenza persa» solo su o1/o7, la card renderizzata mostra tutte le sezioni e lo stato
|
||||
in corso/completato.
|
||||
- **o1/o2/o6** (Supabase locale): scrive eventi e risposte veri su `eventi_app`/
|
||||
`risposte_presenze`, li rilegge con `leggiEventi()` (la stessa funzione server dell'app) e una
|
||||
query REST equivalente a `fetchPresenze()`. Copre: contesto vuoto, aggregazione su più eventi,
|
||||
filtro sui tipi (partite/allenamenti contano, eventi sociali/compleanni no), il mese dinamico
|
||||
(evento dentro/fuori mese), scadenza dinamica.
|
||||
- **o7** (Supabase locale): scrive un allenamento e una partita con convocati e presenze reali,
|
||||
li rilegge con `leggiEventi()`/`leggiPresenze()` e verifica la percentuale (il ritardo conta).
|
||||
- **o7** (Supabase locale): scrive eventi/presenze reali, calcola `contaPresenzeGiocatore()` (la
|
||||
stessa funzione pura usata da `useRosa()` in produzione) sui dati riletti, verifica la somma.
|
||||
- **o11** (Supabase locale): scrive tre allenamenti e presenze reali, calcola
|
||||
`serieConsecutiva()` sui dati riletti, verifica che solo chi resta in serie venga contato.
|
||||
- **o12/o13** (Supabase locale): scrive voti veri su `pagelle_voti` rispettando i vincoli reali
|
||||
|
||||
@@ -328,6 +328,7 @@ Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
- Stato del profilo
|
||||
- Certificato medico
|
||||
- Documento di identità
|
||||
- Stato tesseramento CSI (tesserato / da tesserare)
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
@@ -335,6 +336,7 @@ Azioni disponibili.
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
- Aggiungi giocatore, per inserire un nuovo membro della squadra
|
||||
- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra)
|
||||
@@ -360,9 +362,12 @@ Campi esportati.
|
||||
|
||||
## Tracciamento tesseramento
|
||||
|
||||
Tolto dalla dashboard (DD-048): numero e data della tessera CSI non si registrano più dall'app e non
|
||||
ci sono più il badge «Tesserato»/«Da tesserare» né il conteggio dei tesserati. Le colonne
|
||||
`numero_tessera` e `data_tessera` esistono ancora a database, senza uso.
|
||||
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
|
||||
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
|
||||
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
|
||||
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
|
||||
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
|
||||
complessivo della squadra.
|
||||
|
||||
## Completamento profilo
|
||||
|
||||
|
||||
+17
-63
@@ -1,6 +1,6 @@
|
||||
# Modulo — Scout Live
|
||||
|
||||
**Stato:** implementato (fix M7 per la persistenza condivisa; valutazioni − / + dalla 1.6.0, DD-053)
|
||||
**Stato:** implementato (fix M7 per la persistenza condivisa)
|
||||
**File principali:** `src/lib/scout-live.ts`, `src/lib/scout-stato.ts`, `src/lib/scout-store.ts`,
|
||||
`src/lib/scout-export.ts`, `src/lib/cacche.ts`, `src/components/crapp/ScoutEntry.tsx`,
|
||||
`src/components/crapp/SondaggioCacche.tsx`, `src/routes/scout.tsx`, `src/routes/partita.$id.tsx`
|
||||
@@ -10,9 +10,9 @@
|
||||
## Obiettivo
|
||||
|
||||
Permettere a un solo referente per volta di registrare in tempo reale, durante la partita,
|
||||
un giudizio − / + sui fondamentali (battuta, ricezione, attacco, muro) di ciascun convocato,
|
||||
per set, con salvataggio condiviso su Supabase (non più solo `localStorage`, fix M7) così che chi
|
||||
riprende lo scout trovi lo stato aggiornato, da qualunque dispositivo.
|
||||
punti, ace, muri ed errori di ciascun giocatore in campo, con salvataggio condiviso su
|
||||
Supabase (non più solo `localStorage`, fix M7) così che tutta la squadra veda lo stato
|
||||
aggiornato da qualunque dispositivo.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,9 +20,9 @@ riprende lo scout trovi lo stato aggiornato, da qualunque dispositivo.
|
||||
|
||||
- `scout_sessioni` — chi ha il controllo dello Scout Live per una partita (una riga per
|
||||
`evento_id`, quindi un solo detentore).
|
||||
- `scout_live` — stato in corso (valutazioni, set chiusi, avversario e campo) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni,
|
||||
valutazioni − / +), mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `scout_live` — stato in corso (azioni non ancora concluse) di una sessione.
|
||||
- `scout_partite` — archivio delle partite scoutate concluse (risultato, parziali, azioni),
|
||||
mai più modificato una volta salvato (solo eliminabile per intero).
|
||||
- `cacche_partita` — sondaggio goliardico pre-partita, un voto per giocatore/evento
|
||||
(`UNIQUE evento_id, giocatore_id`).
|
||||
|
||||
@@ -65,52 +65,17 @@ perché occupava spazio 6 giorni su 7.
|
||||
|
||||
## Cosa registra
|
||||
|
||||
La schermata `/scout` ha due passi: **Selezione giocatore** e **Valuta** (vedi sotto), seguiti da
|
||||
«Chiudi set» e «Fine partita».
|
||||
|
||||
- **Selezione giocatore:** griglia dei convocati (chi ha risposto "presente"/"ritardo", con fallback
|
||||
a tutta la rosa se nessuno ha risposto), ordinata per cognome o per numero con il selettore a
|
||||
destra del titolo (default cognome). In ogni riquadro, sotto il nome, compaiono i **+** del
|
||||
giocatore nel set in corso, ricezione esclusa (`contaPiuSenzaRicezione()`).
|
||||
- **Salvataggio** su `scout_live` a ogni cambiamento, senza attesa: i salvataggi vanno in coda, uno
|
||||
alla volta e in ordine, e ognuno scrive lo stato intero (uno riuscito rimedia a uno fallito). Se
|
||||
un salvataggio non riesce compare un avviso fisso, che sparisce al salvataggio successivo riuscito.
|
||||
|
||||
**Punteggio e azioni.** L'interfaccia non registra più il punteggio: sono stati tolti i tasti delle
|
||||
azioni (punto attacco, ace, muro, errore nostro, punto e errore avversario), il tabellone con punti
|
||||
e set, l'elenco «Ultime azioni» e il blocco avversario / in casa / trasferta. Avversario e campo restano quelli ricavati dal
|
||||
titolo della partita (o dallo stato già salvato) e non si modificano. I tipi di azione
|
||||
(`AzioneTipo`, `scout-store.ts`: `attacco`, `ace`, `muro`, `errore`, `punto_avv`, `errore_avv`),
|
||||
i totali `totaliPerGiocatore()` e il campo `scout_partite.azioni` restano nel codice e nei dati:
|
||||
leggono le partite scoutate prima di questa modifica, ma oggi nessuna schermata crea nuove azioni.
|
||||
|
||||
---
|
||||
|
||||
## Valutazioni dei fondamentali (− / +)
|
||||
|
||||
Per ogni convocato lo scout può segnare un **−** o un **+** su battuta, ricezione, attacco e muro
|
||||
([DD-053](../decisions/DD-053.md)). Non sono punti: restano fuori dal punteggio, dai parziali e dai
|
||||
totali P/A/M/E. Si seleziona il giocatore e si tocca − o + sul fondamentale: il titolo della sezione
|
||||
mostra cognome e numero del giocatore e il set in corso, e accanto a ogni tasto compare il conteggio
|
||||
di quel giocatore **nel set in corso**. «Annulla» nella sezione toglie l'ultima valutazione del set in
|
||||
corso (l'«Annulla» in alto agisce solo sulle azioni, quindi oggi è sempre spento).
|
||||
|
||||
Ogni tocco è una riga `Valutazione` (`giocatoreId`, `fondamentale`, `segno`, `set`, `ts`) in una
|
||||
lista separata da `azioni`; i conteggi per set si calcolano con `contaValutazioni()`
|
||||
(`scout-store.ts`). Lo stato in corso le salva in `scout_live.stato.valutazioni` (assente negli
|
||||
stati salvati prima: vale lista vuota); a fine partita finiscono in `scout_partite.valutazioni`.
|
||||
Tipi di azione (`AzioneTipo`, `scout-store.ts`): `attacco`, `ace`, `muro`, `errore`,
|
||||
`punto_avv`, `errore_avv` — attacco/ace/muro ed errore avversario valgono come punto nostro,
|
||||
errore nostro e punto avversario come punto avversario. Le azioni con giocatore
|
||||
(attacco/ace/muro/errore) richiedono di selezionarlo prima dalla griglia dei convocati
|
||||
(filtrati sulle risposte "presente"/"ritardo", con fallback a tutta la rosa se nessuno ha
|
||||
risposto). Salvataggio automatico su `scout_live` con debounce di 800ms a ogni cambiamento.
|
||||
|
||||
---
|
||||
|
||||
## Fine partita
|
||||
|
||||
Senza punteggio, `chiudiSet()` chiude il set (come 0-0) se ha almeno una valutazione, e
|
||||
`finePartita()` archivia anche la partita fatta di sole valutazioni: il set in corso entra nei
|
||||
parziali se ha dati (`parzialiFinali()`, `scout-store.ts`). Con nessuna valutazione risponde
|
||||
«Nessun set da salvare». Una partita senza punti ha parziali 0-0 e **non è un risultato**:
|
||||
`haPunteggio()` la esclude da Home, Classifica e dalla pagina della partita, dove non sostituisce il
|
||||
referto CSI; resta visibile il «Report tecnico» con il CSV.
|
||||
|
||||
`finePartita()` (`scout.tsx`) compone i parziali finali, inserisce la partita in
|
||||
`scout_partite` (INSERT, non upsert), poi cancella la riga da `scout_live` (stato consumato)
|
||||
e rilascia la sessione.
|
||||
@@ -119,10 +84,9 @@ e rilascia la sessione.
|
||||
|
||||
## Export CSV
|
||||
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per giocatore e
|
||||
log cronologico delle azioni. Se ci sono valutazioni, aggiunge una tabella per set e giocatore con
|
||||
i conteggi di «più» e «meno» di ogni fondamentale (solo per chi ne ha). Scaricabile dagli admin dalla
|
||||
pagina partita, sezione "Report tecnico".
|
||||
`scout-export.ts` genera un CSV (separatore `;`, BOM UTF-8) con parziali, riepilogo per
|
||||
giocatore e log cronologico delle azioni. Scaricabile dagli admin dalla pagina partita,
|
||||
sezione "Report tecnico".
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +107,7 @@ calcolati a runtime).
|
||||
|
||||
## Regole rispettate
|
||||
|
||||
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri e valutazioni) restano confinati allo Scout
|
||||
- **DD-008 (gamification equa)**: i dati tecnici (punti/ace/muri) restano confinati allo Scout
|
||||
Live come statistica di squadra e non entrano nel tipo `Giocatore` usato per badge o
|
||||
classifiche individuali.
|
||||
|
||||
@@ -151,18 +115,10 @@ calcolati a runtime).
|
||||
|
||||
## Limiti noti
|
||||
|
||||
- Lo stato in corso è raggiungibile solo il giorno della partita: se nessuno preme «Fine partita»
|
||||
entro la mezzanotte i dati restano in `scout_live` ma dall'app non si riprendono più.
|
||||
- Le valutazioni dei set chiusi e delle partite finite sono salvate ma non si rivedono nell'app: lo
|
||||
scout mostra solo il set in corso e l'unica lettura è il CSV admin. Evoluzione: tabella nella
|
||||
pagina partita.
|
||||
- Il blocco «Report tecnico» della pagina partita mostra ancora Punti/Ace/Muri/Errori (a zero per le
|
||||
partite di sole valutazioni) e non le valutazioni, che sono solo nel CSV admin.
|
||||
- Scout aperto a tutta la squadra: nessun filtro su chi può registrare le azioni, l'unica
|
||||
garanzia è il lock di sessione (vedi sopra).
|
||||
- Possibile, per quanto improbabile, doppio "successo" applicativo nel prendere il lock:
|
||||
lettura e upsert non sono atomici.
|
||||
- Avversario e campo non si correggono dallo scout.
|
||||
- `scout_partite` si inserisce ma non si corregge dall'interfaccia: solo eliminazione totale.
|
||||
- Abbinamento partita↔scout fatto anche per uguaglianza di data come fallback: ambiguo se due
|
||||
partite cadono lo stesso giorno.
|
||||
@@ -171,6 +127,4 @@ calcolati a runtime).
|
||||
|
||||
## Evoluzioni possibili
|
||||
|
||||
- Mostrare le valutazioni per set e giocatore nella pagina della partita (oggi solo nel CSV admin)
|
||||
e, nello scout, in sola lettura quelle dei set chiusi.
|
||||
- Realtime (Supabase Realtime) per aggiornare la sessione condivisa senza refresh manuale.
|
||||
@@ -1,12 +1,12 @@
|
||||
# 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`,
|
||||
`src/components/crapp/SerieCard.tsx`
|
||||
**Migration collegata:** `m9_risposte_presenze_risposto_il`
|
||||
**Test:** `test/unit/serie.test.ts`, `test/unit/presenze.test.ts`,
|
||||
`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 |
|
||||
| ------------- | ------------------ | -------------------------------------------- | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `partite` | `seriePartite` | Partite passate consecutive con presenza | 2, 5, 8, 12 |
|
||||
| Tipo | Campo `Giocatore` | Cosa conta | Traguardi |
|
||||
| ------------- | ------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| `allenamenti` | `serieAllenamenti` | Allenamenti passati consecutivi con presenza | 3, 6, 10, 15 |
|
||||
| `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
|
||||
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
|
||||
Esiste un quarto 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 consecutive" in `src/routes/index.tsx`, `src/routes/squadra.tsx` e
|
||||
`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
|
||||
rosso. Il test precedente guardava `aggiornato_il` e passava anche senza trigger.
|
||||
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | -------------------------------------- |
|
||||
| `risposto_il` | la **prima** risposta | nessuna statistica (ex «Conferme 24h») |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
| Colonna | Cosa registra | Chi la usa |
|
||||
| --------------- | --------------------- | ----------------------- |
|
||||
| `risposto_il` | la **prima** risposta | la serie "Conferme 24h" |
|
||||
| `aggiornato_il` | l'**ultima** modifica | nessuna statistica |
|
||||
|
||||
Cancellare la risposta (`stato: null` → DELETE) elimina anche `risposto_il`: se il giocatore
|
||||
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.
|
||||
- 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
|
||||
|
||||
`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 |
|
||||
| ------------------------------- | -------------------------------------------------------------- | --------------------------- |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
@@ -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
|
||||
`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
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()` e `serieConferme()` (poi rimossa) calcolavano `oggi` con
|
||||
offset scritto a mano. È lo stesso `oggi` di `serieConsecutiva()`, `serieConferme()` e del
|
||||
conteggio presenze — prima `serieConsecutiva()`/`serieConferme()` calcolavano `oggi` con
|
||||
`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
|
||||
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
|
||||
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
|
||||
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche»). La
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
|
||||
specifica completa sta in [allenatore.md](allenatore.md).
|
||||
|
||||
## Gestione dati squadra (solo amministratore)
|
||||
|
||||
Da `/admin` un amministratore può ([DD-017](../decisions/DD-017.md)):
|
||||
|
||||
| Azione | Hook | Effetto |
|
||||
| ----------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Azione | Hook | Effetto |
|
||||
| ------------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../decisions/DD-016.md) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
|
||||
Generated
+2046
-249
File diff suppressed because it is too large.
Load diff
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.6.0",
|
||||
"version": "1.2.3",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
@@ -20,10 +20,10 @@
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.111.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"@tanstack/react-router": "^1.170.41",
|
||||
"@tanstack/react-start": "^1.168.60",
|
||||
"@tanstack/router-plugin": "^1.168.42",
|
||||
"@tanstack/react-query": "^5.101.1",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-start": "^1.168.32",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
@@ -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 { Avviso, type RigaAvviso } from "@/components/crapp/Avviso";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificatiUtente,
|
||||
@@ -12,6 +12,45 @@ import { oggiISO } from "@/lib/palloni-core";
|
||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
function Card({
|
||||
colore,
|
||||
titolo,
|
||||
righe,
|
||||
cliccabile,
|
||||
}: {
|
||||
colore: "warning" | "primary" | "destructive";
|
||||
titolo: string;
|
||||
righe: string[];
|
||||
cliccabile?: boolean;
|
||||
}) {
|
||||
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||
colore === "warning"
|
||||
? "bg-warning text-warning-foreground"
|
||||
: colore === "destructive"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
} ${cliccabile ? "premi" : ""}`;
|
||||
const contenuto = (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||
</p>
|
||||
{righe.map((r) => (
|
||||
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return cliccabile ? (
|
||||
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classi}>{contenuto}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Avviso certificati mancanti/scaduti/in scadenza (DD-035, DD-041, DD-046): calcolato al volo, niente push.
|
||||
* Il giocatore lo vede da 30 giorni prima della scadenza, lo staff da 7.
|
||||
@@ -33,31 +72,28 @@ export function AvvisoCertificati() {
|
||||
base,
|
||||
});
|
||||
const cardPersonale = personaleMancante ? (
|
||||
<Avviso
|
||||
tono="critico"
|
||||
icona={FileX}
|
||||
<Card
|
||||
colore="destructive"
|
||||
titolo="Certificato mancante"
|
||||
cliccabile
|
||||
testi={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
||||
righe={["Non hai ancora caricato il certificato medico: caricalo nei documenti"]}
|
||||
/>
|
||||
) : personale ? (
|
||||
personale.giorni < 0 ? (
|
||||
<Avviso
|
||||
tono="scaduto"
|
||||
icona={FileClock}
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
testi={[
|
||||
righe={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(personale.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Avviso
|
||||
tono="attenzione"
|
||||
icona={CalendarClock}
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
testi={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
||||
righe={[`Il tuo certificato medico ${testoScadenza(personale.giorni)}`]}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
@@ -68,33 +104,30 @@ export function AvvisoCertificati() {
|
||||
<>
|
||||
{cardPersonale}
|
||||
{mancanti.length > 0 ? (
|
||||
<Avviso
|
||||
tono="critico"
|
||||
icona={FileX}
|
||||
<Card
|
||||
colore="destructive"
|
||||
titolo="Certificati mancanti"
|
||||
elenco={mancanti.map((m) => ({ chi: `${m.nome} ${m.cognome}`, cosa: "non caricato" }))}
|
||||
righe={mancanti.map((m) => `${m.nome} ${m.cognome} — non caricato`)}
|
||||
/>
|
||||
) : null}
|
||||
{scaduti.length > 0 ? (
|
||||
<Avviso
|
||||
tono="scaduto"
|
||||
icona={FileClock}
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificati scaduti"
|
||||
elenco={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Avviso
|
||||
tono="attenzione"
|
||||
icona={CalendarClock}
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificati in scadenza"
|
||||
elenco={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): RigaAvviso {
|
||||
return { chi: `${a.nome} ${a.cognome}`, cosa: dettaglio };
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="text-xl font-bold leading-tight">{def.nome}</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
||||
{def.descrizione}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
@@ -42,15 +42,13 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
{stato ? (
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Stato attuale
|
||||
</p>
|
||||
{grado ? (
|
||||
<span className={cn("text-[13px] font-bold uppercase", meta?.text)}>
|
||||
{meta?.label}
|
||||
</span>
|
||||
<span className={cn("text-xs font-bold uppercase", meta?.text)}>{meta?.label}</span>
|
||||
) : (
|
||||
<span className="text-[13px] font-bold uppercase text-muted-foreground">
|
||||
<span className="text-xs font-bold uppercase text-muted-foreground">
|
||||
In progresso
|
||||
</span>
|
||||
)}
|
||||
@@ -61,19 +59,19 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
{stato.prossimaSoglia ? (
|
||||
<>
|
||||
<Barra percentuale={stato.progresso} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Mancano {stato.prossimaSoglia - stato.valore} {def.unita} per il{" "}
|
||||
{gradoMeta[stato.prossimo as Grado].label.toLowerCase()}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-[13px] text-success">Hai raggiunto il livello massimo.</p>
|
||||
<p className="mt-2 text-xs text-success">Hai raggiunto il livello massimo.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Soglie
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -90,7 +88,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] font-bold uppercase",
|
||||
"text-xs font-bold uppercase",
|
||||
raggiunto ? gm.text : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -104,7 +102,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
>
|
||||
{def.soglie[g]}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">{def.unita}</p>
|
||||
<p className="text-xs text-muted-foreground">{def.unita}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -112,7 +110,7 @@ function DettaglioBadge({ def, stato }: { def: BadgeDef; stato?: BadgeStato }) {
|
||||
</div>
|
||||
|
||||
{def.celebrazione ? (
|
||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-[15px] font-semibold text-accent">
|
||||
<p className="rounded-2xl bg-accent/10 p-3 text-center text-sm font-semibold text-accent">
|
||||
“{def.celebrazione}”
|
||||
</p>
|
||||
) : null}
|
||||
@@ -144,7 +142,7 @@ export function BadgeDrawer({
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Indietro
|
||||
</button>
|
||||
|
||||
@@ -51,11 +51,11 @@ export function CelebrazioneBadge() {
|
||||
{notifica.emoji}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||
{notifica.tono === "segreto" ? "Badge segreto sbloccato" : "Traguardo raggiunto"}
|
||||
</p>
|
||||
<p className="mt-0.5 font-display text-2xl leading-none">{notifica.titolo}</p>
|
||||
<p className="mt-1.5 text-[15px] text-muted-foreground">{notifica.testo}</p>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{notifica.testo}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -69,7 +69,7 @@ export function CelebrazioneBadge() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={chiudi}
|
||||
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-[15px] font-bold uppercase tracking-wide text-accent-foreground"
|
||||
className="mt-4 w-full rounded-2xl bg-accent-grad px-4 py-3 text-sm font-bold uppercase tracking-wide text-accent-foreground"
|
||||
>
|
||||
{restanti > 0 ? `Avanti (${restanti})` : "Grande!"}
|
||||
</button>
|
||||
|
||||
@@ -45,21 +45,21 @@ function CardBadge({ b, opaco, indice = 0 }: { b: BadgeStato; opaco?: boolean; i
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon className={cn("h-6 w-6", meta ? meta.text : "text-muted-foreground/50")} />
|
||||
{meta ? (
|
||||
<span className={cn("text-[13px] font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||
<span className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
</p>
|
||||
{b.prossimaSoglia ? (
|
||||
<>
|
||||
<Barra percentuale={b.progresso} altezza="h-1.5" trackClassName="mt-2" />
|
||||
<p className="mt-1 text-[13px] font-semibold text-accent">{mancanoPer(b)}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">{mancanoPer(b)}</p>
|
||||
<p className="text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{microcopyBadge(b)}</p>
|
||||
)}
|
||||
</Reveal>
|
||||
);
|
||||
@@ -90,15 +90,13 @@ function SocialDrawer({
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<DrawerTitle className="text-xl font-bold leading-tight">{cat.nome}</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 text-[15px] leading-relaxed">
|
||||
<DrawerDescription className="mt-1 text-sm leading-relaxed">
|
||||
{cat.descrizione}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-card p-4 shadow-card ring-1 ring-border">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Vinto
|
||||
</p>
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Vinto</p>
|
||||
<p className="mt-1 font-display text-3xl leading-none">
|
||||
{conteggio}{" "}
|
||||
<span className="text-lg text-muted-foreground">
|
||||
@@ -111,7 +109,7 @@ function SocialDrawer({
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-semibold text-foreground transition-colors hover:bg-secondary/80"
|
||||
>
|
||||
Indietro
|
||||
</button>
|
||||
@@ -141,7 +139,7 @@ export function CollezioneBadge({
|
||||
<div className="rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
Collezione badge
|
||||
</p>
|
||||
<p className="font-display text-4xl leading-none">
|
||||
@@ -149,7 +147,7 @@ export function CollezioneBadge({
|
||||
<span className="text-2xl text-primary-foreground/50">/{c.totali}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[13px] text-primary-foreground/70">
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
<Numero valore={pct} suffisso="%" /> completata
|
||||
</p>
|
||||
</div>
|
||||
@@ -158,12 +156,12 @@ export function CollezioneBadge({
|
||||
|
||||
{vicino ? (
|
||||
<div className="rounded-3xl bg-card p-4 shadow-card ring-1 ring-accent/30">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-accent">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-accent">
|
||||
Prossimo traguardo
|
||||
</p>
|
||||
<p className="mt-1 text-[15px] font-bold leading-tight">{vicino.def.nome}</p>
|
||||
<p className="mt-1 text-sm font-bold leading-tight">{vicino.def.nome}</p>
|
||||
<Barra percentuale={vicino.progresso} trackClassName="mt-2" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{mancanoPer(vicino)} · {microcopyBadge(vicino)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -171,7 +169,7 @@ export function CollezioneBadge({
|
||||
|
||||
{c.sbloccati.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Sbloccati ({c.sbloccati.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -186,7 +184,7 @@ export function CollezioneBadge({
|
||||
|
||||
{c.inProgresso.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
In progresso ({c.inProgresso.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -201,7 +199,7 @@ export function CollezioneBadge({
|
||||
|
||||
{socialVinti.length > 0 ? (
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Votati dai compagni
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -209,8 +207,8 @@ export function CollezioneBadge({
|
||||
<SocialDrawer key={cat.id} cat={cat} conteggio={social[cat.id] ?? 0}>
|
||||
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-accent/25">
|
||||
<p className="text-lg leading-none">{cat.emoji}</p>
|
||||
<p className="mt-1 text-[15px] font-bold leading-tight">{cat.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-sm font-bold leading-tight">{cat.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vinto {social[cat.id]} {social[cat.id] === 1 ? "volta" : "volte"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -221,7 +219,7 @@ export function CollezioneBadge({
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge segreti
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -231,8 +229,8 @@ export function CollezioneBadge({
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className="rounded-2xl bg-card p-3 shadow-card ring-1 ring-oro/40">
|
||||
<Icon className="h-6 w-6 text-oro" />
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.def.celebrazione ?? "Badge segreto sbloccato."}
|
||||
</p>
|
||||
</div>
|
||||
@@ -245,10 +243,8 @@ export function CollezioneBadge({
|
||||
className="grid place-items-center rounded-2xl bg-secondary/60 p-3 text-center"
|
||||
>
|
||||
<Lucchetto className="h-6 w-6 text-muted-foreground/50" />
|
||||
<p className="mt-1.5 text-[15px] font-bold leading-tight text-muted-foreground">
|
||||
???
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||
<p className="mt-1.5 text-sm font-bold leading-tight text-muted-foreground">???</p>
|
||||
<p className="text-xs text-muted-foreground/70">Badge segreto da scoprire</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function MetaPartitaCsi({
|
||||
.join(" · ");
|
||||
if (!voci && !link) return null;
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{voci ? <span>{voci}</span> : null}
|
||||
{link ? (
|
||||
<a
|
||||
@@ -68,17 +68,17 @@ export function MetaPartitaCsi({
|
||||
function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{squadra.squadra}
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{squadra.titolari.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[15px]">
|
||||
<span className="w-6 shrink-0 text-center font-display text-[13px] text-accent">
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-6 shrink-0 text-center font-display text-xs text-accent">
|
||||
{g.numero}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
{g.ruolo ? <span className="text-[13px] text-muted-foreground">{g.ruolo}</span> : null}
|
||||
{g.ruolo ? <span className="text-xs text-muted-foreground">{g.ruolo}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
{squadra.panchina.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="w-6 shrink-0 text-center">{g.numero}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{g.nome}</span>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ function ListaFormazione({ squadra }: { squadra: FormazioneSquadra }) {
|
||||
</>
|
||||
) : null}
|
||||
{squadra.staff.length > 0 ? (
|
||||
<div className="mt-3 space-y-0.5 text-[13px] text-muted-foreground">
|
||||
<div className="mt-3 space-y-0.5 text-xs text-muted-foreground">
|
||||
{squadra.staff.map((s, i) => (
|
||||
<div key={i}>
|
||||
{s.nome} · {s.ruolo}
|
||||
@@ -121,7 +121,7 @@ function BarraPrecedenti({
|
||||
}) {
|
||||
const totale = noi + avversario || 1;
|
||||
return (
|
||||
<div className="text-[13px]">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span className="font-bold text-foreground">{noi}</span>
|
||||
<span>{label}</span>
|
||||
@@ -139,7 +139,7 @@ const NOME_NOI = "CRAP Volley";
|
||||
/** Intestazione con i nomi delle due squadre, per non dover ripeterli su ogni barra sotto. */
|
||||
function TestataSquadre({ avversario }: { avversario: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-[13px] font-bold">
|
||||
<div className="flex items-center justify-between text-xs font-bold">
|
||||
<span className="truncate text-accent">{NOME_NOI}</span>
|
||||
<span className="truncate text-right text-muted-foreground">{avversario}</span>
|
||||
</div>
|
||||
@@ -158,7 +158,7 @@ function BarraProbabilita({
|
||||
const favoritaNoi = noi >= avversarioPct;
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] text-muted-foreground">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Probabilità di vittoria (calcolo CSI):{" "}
|
||||
<span className="font-bold text-foreground">{favoritaNoi ? NOME_NOI : avversario}</span>{" "}
|
||||
favorita al {Math.max(noi, avversarioPct).toFixed(0)}%.
|
||||
@@ -171,13 +171,13 @@ function BarraProbabilita({
|
||||
</div>
|
||||
<div className="flex h-7 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="flex items-center justify-center bg-success text-[13px] font-bold text-success-foreground"
|
||||
className="flex items-center justify-center bg-success text-xs font-bold text-success-foreground"
|
||||
style={{ width: `${noi}%` }}
|
||||
>
|
||||
{noi.toFixed(0)}%
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-center bg-destructive text-[13px] font-bold text-destructive-foreground"
|
||||
className="flex items-center justify-center bg-destructive text-xs font-bold text-destructive-foreground"
|
||||
style={{ width: `${avversarioPct}%` }}
|
||||
>
|
||||
{avversarioPct.toFixed(0)}%
|
||||
@@ -197,7 +197,7 @@ function BloccoPrecedenti({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TestataSquadre avversario={avversario} />
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{precedenti.totale > 0
|
||||
? `${precedenti.totale} precedenti in archivio sul portale CSI.`
|
||||
: "Nessun precedente in archivio sul portale CSI: primo confronto tra le due squadre."}
|
||||
@@ -248,22 +248,20 @@ export function DettaglioCsiEsteso({
|
||||
const { data, isLoading } = useCsiPartita(matchId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="px-1 text-center text-[13px] text-muted-foreground">Carico i dati dal CSI…</p>
|
||||
);
|
||||
return <p className="px-1 text-center text-xs text-muted-foreground">Carico i dati dal CSI…</p>;
|
||||
}
|
||||
if (!data || (!data.formazioni && !data.precedenti)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data.giornata || data.nota ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[data.giornata, data.nota].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{data.formazioni ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<Users2 className="h-4 w-4 text-accent" /> Formazioni
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@@ -274,7 +272,7 @@ export function DettaglioCsiEsteso({
|
||||
) : null}
|
||||
{data.precedenti ? (
|
||||
<Card>
|
||||
<div className="mb-3 flex items-center gap-2 text-[15px] font-bold">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-bold">
|
||||
<ShieldAlert className="h-4 w-4 text-accent" /> Scontri diretti
|
||||
</div>
|
||||
<BloccoPrecedenti precedenti={data.precedenti} avversario={avversario} />
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
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 { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
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.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
|
||||
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
@@ -102,10 +101,10 @@ export function EventoCard({
|
||||
<Cake className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-[15px] font-bold leading-tight">{evento.titolo}</h3>
|
||||
<p className="text-[13px] text-muted-foreground">{evento.luogo}</p>
|
||||
<h3 className="truncate text-sm font-bold leading-tight">{evento.titolo}</h3>
|
||||
<p className="text-xs text-muted-foreground">{evento.luogo}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<div className="shrink-0 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -140,13 +139,13 @@ export function EventoCard({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded-full px-2.5 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
||||
"inline-block rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
tipo.className,
|
||||
)}
|
||||
>
|
||||
{tipo.label}
|
||||
</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-[15px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-sm font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{etichettaData(evento.data)}
|
||||
{linkTo ? <ChevronRight className="h-4 w-4" aria-hidden /> : null}
|
||||
</span>
|
||||
@@ -154,10 +153,10 @@ export function EventoCard({
|
||||
|
||||
<h3 className="mt-2 text-base font-bold leading-tight">{evento.titolo}</h3>
|
||||
{sottotitoloPartita ? (
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">{sottotitoloPartita}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{sottotitoloPartita}</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[14px] text-muted-foreground">
|
||||
<div className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 text-[13px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{evento.ora}</span>
|
||||
@@ -175,12 +174,12 @@ export function EventoCard({
|
||||
</div>
|
||||
|
||||
{noteCard ? (
|
||||
<p className="mt-2.5 line-clamp-2 text-[13px] text-muted-foreground">📝 {noteCard}</p>
|
||||
<p className="mt-2.5 line-clamp-2 text-xs text-muted-foreground">📝 {noteCard}</p>
|
||||
) : null}
|
||||
|
||||
{metaStato ? (
|
||||
<p
|
||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-[13px] font-semibold text-muted-foreground"
|
||||
className="mt-3 flex items-center gap-1.5 border-t border-border pt-2.5 text-left text-xs font-semibold text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span aria-hidden>{metaStato.emoji}</span>
|
||||
@@ -188,7 +187,7 @@ export function EventoCard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{io && eConvocato(evento, io.id) ? (
|
||||
{io ? (
|
||||
<div
|
||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||
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 (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<ClipboardCheck className="h-3.5 w-3.5" /> Pagelle anonime
|
||||
</p>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||
{chiuse ? "Votazioni chiuse" : `${fatti}/${daVotare.length} votati`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Dai un voto da 1 a 10 ai compagni: nessuno vedrà chi ha votato cosa, solo la media.
|
||||
</p>
|
||||
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="mt-3 text-[13px] text-muted-foreground">
|
||||
<p aria-busy="true" className="mt-3 text-xs text-muted-foreground">
|
||||
Carico le pagelle…
|
||||
</p>
|
||||
) : (
|
||||
@@ -70,17 +70,15 @@ export function Pagelle({
|
||||
return (
|
||||
<div key={g.id} className="rounded-2xl bg-secondary/60 p-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
{g.nome}
|
||||
</span>
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{media ? `Media ${media.media} · ${media.voti} voti` : "Nessun voto"}
|
||||
</span>
|
||||
</span>
|
||||
{sonoIo ? (
|
||||
<span className="shrink-0 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
<span className="shrink-0 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Sei tu
|
||||
</span>
|
||||
) : chiuse ? (
|
||||
@@ -91,7 +89,7 @@ export function Pagelle({
|
||||
disabled={!io || vota.isPending}
|
||||
onClick={() => setApertoPer(aperto ? null : g.id)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-1 text-[13px] font-bold uppercase transition-colors disabled:opacity-50",
|
||||
"shrink-0 rounded-full px-3 py-1 text-xs font-bold uppercase transition-colors disabled:opacity-50",
|
||||
mio !== undefined
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
@@ -109,7 +107,7 @@ export function Pagelle({
|
||||
type="button"
|
||||
onClick={() => invia(g.id, v)}
|
||||
className={cn(
|
||||
"rounded-lg py-1.5 text-[13px] font-bold tabular-nums transition-transform active:scale-90",
|
||||
"rounded-lg py-1.5 text-xs font-bold tabular-nums transition-transform active:scale-90",
|
||||
mio === v
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
|
||||
@@ -27,7 +27,7 @@ const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
|
||||
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<h3 className="font-display text-[15px] uppercase tracking-wide">{titolo}</h3>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -67,7 +67,7 @@ function CampoFile({
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="flex min-w-0 items-center gap-2 text-[15px]">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
||||
@@ -93,7 +93,7 @@ function CampoFile({
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={inCorso}
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-[13px] font-bold text-primary-foreground disabled:opacity-60"
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
||||
</button>
|
||||
@@ -336,7 +336,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -366,7 +366,7 @@ export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
type="button"
|
||||
onClick={() => void salva()}
|
||||
disabled={!sporco || inCorso}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
@@ -430,7 +430,7 @@ export function ProfiloAmministrativo({
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -472,7 +472,7 @@ export function ProfiloAmministrativo({
|
||||
type="button"
|
||||
onClick={salvaBozza}
|
||||
disabled={!sporco || salva.isPending}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
@@ -486,9 +486,7 @@ export function ProfiloAmministrativo({
|
||||
<SezioneTendina
|
||||
titolo="Dati per il tesseramento"
|
||||
indice={indice}
|
||||
azione={
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
}
|
||||
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
||||
>
|
||||
{corpo}
|
||||
</SezioneTendina>
|
||||
@@ -523,10 +521,10 @@ export function CompletaProfilo({
|
||||
className="premi block rounded-3xl bg-card p-4 shadow-card"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-display text-[16px] uppercase tracking-wide">
|
||||
<span className="font-display text-[15px] uppercase tracking-wide">
|
||||
Completa il tuo profilo
|
||||
</span>
|
||||
<span className="text-[14px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
<span className="text-[13px] font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
@@ -534,7 +532,7 @@ export function CompletaProfilo({
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[14px] text-muted-foreground">
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
{allenatore
|
||||
? "Data e luogo di nascita, telefono ed email."
|
||||
: "Documento e certificato medico servono per il tesseramento CSI."}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Volleyball } from "lucide-react";
|
||||
import { Avviso } from "@/components/crapp/Avviso";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
@@ -18,8 +17,6 @@ export function PromemoriaPalloni() {
|
||||
|
||||
const oggi = oggiISO();
|
||||
const messaggi: string[] = [];
|
||||
// «Oggi» solo se c'è davvero qualcosa da fare oggi; altrimenti è un semplice promemoria.
|
||||
let oggiTocca = true;
|
||||
|
||||
for (const evento of lista) {
|
||||
if (evento.data !== oggi) continue;
|
||||
@@ -39,7 +36,6 @@ export function PromemoriaPalloni() {
|
||||
}
|
||||
|
||||
if (messaggi.length === 0) {
|
||||
oggiTocca = false;
|
||||
const prossimo = lista.find((e) => e.data >= oggi && turni[e.id] === io.id);
|
||||
if (!prossimo) return null;
|
||||
messaggi.push(
|
||||
@@ -48,13 +44,15 @@ export function PromemoriaPalloni() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Avviso
|
||||
tono="azione"
|
||||
icona={Volleyball}
|
||||
titolo="Turno palloni"
|
||||
etichetta={oggiTocca ? "Oggi" : "In arrivo"}
|
||||
testi={messaggi}
|
||||
riquadri
|
||||
/>
|
||||
<div className="mx-5 mt-4 rounded-3xl bg-accent-grad p-4 text-accent-foreground shadow-pop">
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> Turno palloni
|
||||
</p>
|
||||
{messaggi.map((m) => (
|
||||
<p key={m} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{m}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
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 { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
@@ -20,12 +20,9 @@ export function RosaPresenze({
|
||||
eventoId,
|
||||
data,
|
||||
tipo = "allenamento",
|
||||
convocati = [],
|
||||
}: {
|
||||
eventoId: 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. */
|
||||
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).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const anagrafica = useAnagraficaRosa();
|
||||
const rosa = rosaConvocata(anagrafica, convocati);
|
||||
const rosa = useAnagraficaRosa();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
|
||||
@@ -74,7 +70,7 @@ export function RosaPresenze({
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-[15px] font-bold">
|
||||
<p className="text-sm font-bold">
|
||||
Hanno risposto {risposteN}/{rosa.length}
|
||||
</p>
|
||||
<span className="font-display text-xl leading-none">{perc}%</span>
|
||||
@@ -88,7 +84,7 @@ export function RosaPresenze({
|
||||
<span
|
||||
key={s}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[13px] font-bold",
|
||||
"rounded-full px-2.5 py-1 text-xs font-bold",
|
||||
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -96,14 +92,14 @@ export function RosaPresenze({
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold text-muted-foreground">
|
||||
❔ {mancanti.length} da rispondere
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{io && rosa.some((g) => g.id === io.id) ? (
|
||||
{io ? (
|
||||
<div className="mt-4 border-t border-border pt-3">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
La tua risposta
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
@@ -118,7 +114,7 @@ export function RosaPresenze({
|
||||
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
|
||||
}
|
||||
className={cn(
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||
"rounded-full border border-border px-2.5 py-1.5 text-xs font-semibold transition-all active:scale-95 disabled:opacity-50",
|
||||
attivo
|
||||
? cn(statoMeta[s].className, "border-transparent shadow-card")
|
||||
: "bg-background text-muted-foreground",
|
||||
@@ -137,7 +133,7 @@ export function RosaPresenze({
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
disabled={sollecito || daSollecitare === 0 || passato}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{sollecito ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -150,7 +146,7 @@ export function RosaPresenze({
|
||||
</Card>
|
||||
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
|
||||
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
|
||||
Carico le risposte…
|
||||
</p>
|
||||
) : null}
|
||||
@@ -198,16 +194,16 @@ function Gruppo({
|
||||
attenzione && "border border-dashed border-warning",
|
||||
)}
|
||||
>
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{titolo} · {n}
|
||||
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{lista.map((g) => (
|
||||
<div key={g.id} className="flex items-center gap-3">
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
|
||||
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-xs" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{g.nome}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{g.ruolo}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ScoutEntry({
|
||||
{occupato ? <Lock className="h-5 w-5 shrink-0" /> : <Radio className="h-5 w-5 shrink-0" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-lg uppercase leading-none">{titolo}</span>
|
||||
<span className="block text-[13px] opacity-80">{sottotitolo}</span>
|
||||
<span className="block text-xs opacity-80">{sottotitolo}</span>
|
||||
</span>
|
||||
{disponibile ? <ChevronRight className="h-5 w-5 shrink-0" /> : null}
|
||||
</>
|
||||
|
||||
@@ -28,8 +28,8 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-bold leading-tight">{s.def.label}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{s.def.descrizione}</p>
|
||||
<p className="text-sm font-bold leading-tight">{s.def.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.def.descrizione}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame
|
||||
@@ -39,7 +39,7 @@ export function SerieGriglia({ g }: { g: Giocatore }) {
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={s.progresso} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{s.prossimo ? `${s.valore}/${s.prossimo} · ` : ""}
|
||||
{s.messaggio}
|
||||
</p>
|
||||
@@ -61,17 +61,17 @@ export function SerieHome({ g }: { g: Giocatore }) {
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-bold leading-tight">
|
||||
<p className="text-sm font-bold leading-tight">
|
||||
Serie {top.def.label.toLowerCase()}: {top.valore}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">{top.messaggio}</p>
|
||||
<p className="text-xs text-muted-foreground">{top.messaggio}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{serieGiocatore(g).map((s) => (
|
||||
<div key={s.def.tipo} className="rounded-2xl bg-secondary p-2 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.valore}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{s.def.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -81,10 +81,10 @@ export function SondaggioCacche({
|
||||
if (!sondaggioAperto(dataEvento, oraEvento)) {
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
💩 Sondaggio pre-partita
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{sondaggioTerminato(dataEvento, oraEvento)
|
||||
? "Sondaggio chiuso: era aperto fino al fischio d'inizio."
|
||||
: "Apre alle 8:00 del giorno della partita: riceverai una notifica."}
|
||||
@@ -96,14 +96,14 @@ export function SondaggioCacche({
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
💩 Sondaggio pre-partita
|
||||
</p>
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-bold uppercase text-muted-foreground">
|
||||
{dellaPartita.length} risposte
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Quante cacche hai fatto prima di questa partita? Dato scientifico fondamentale.
|
||||
</p>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function SondaggioCacche({
|
||||
disabled={!io || salva.isPending}
|
||||
onClick={() => rispondi(n)}
|
||||
className={cn(
|
||||
"min-w-11 rounded-xl px-3 py-2 text-[15px] font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||
"min-w-11 rounded-xl px-3 py-2 text-sm font-bold tabular-nums transition-transform active:scale-90 disabled:opacity-50",
|
||||
mia?.quantita === n
|
||||
? "bg-accent text-accent-foreground shadow-pop"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -126,7 +126,7 @@ export function SondaggioCacche({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[13px]">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-secondary px-2.5 py-1 font-semibold">
|
||||
Media squadra {media}
|
||||
</span>
|
||||
@@ -145,7 +145,7 @@ export function SondaggioCacche({
|
||||
type="button"
|
||||
onClick={avvisaTutti}
|
||||
disabled={avviso}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-bold text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{avviso ? <Loader2 className="h-4 w-4 animate-spin" /> : <BellRing className="h-4 w-4" />}
|
||||
Avvisa tutti del sondaggio
|
||||
|
||||
@@ -74,16 +74,16 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
<CircleDot className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="block text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Palloni
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-[15px] font-bold leading-tight">
|
||||
<span className="flex items-center gap-1.5 text-sm font-bold leading-tight">
|
||||
{isPending || assegna.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="truncate">{giocatore ? nomeCompleto(giocatore) : "Da assegnare"}</span>
|
||||
{proposto && giocatore ? (
|
||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
<span className="shrink-0 rounded-full bg-card px-1.5 py-0.5 text-xs font-bold uppercase text-muted-foreground">
|
||||
proposto
|
||||
</span>
|
||||
) : null}
|
||||
@@ -100,11 +100,11 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
type="button"
|
||||
onClick={() => scegli(g.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[15px] transition-colors",
|
||||
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors",
|
||||
g.id === id ? "bg-accent/10 font-bold" : "hover:bg-secondary",
|
||||
)}
|
||||
>
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-[13px]" />
|
||||
<Avatar id={g.id} fallback={String(g.numero)} className="h-7 w-7 text-xs" />
|
||||
<span className="min-w-0 flex-1 truncate">{nomeCompleto(g)}</span>
|
||||
{g.id === id ? <Check className="h-4 w-4 shrink-0 text-accent" /> : null}
|
||||
</button>
|
||||
@@ -117,7 +117,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
type="button"
|
||||
onClick={avvisa}
|
||||
disabled={avviso}
|
||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-[13px] font-bold uppercase text-foreground disabled:opacity-50"
|
||||
className="premi mt-2.5 flex w-full items-center justify-center gap-2 rounded-xl bg-card px-3 py-2 text-xs font-bold uppercase text-foreground disabled:opacity-50"
|
||||
>
|
||||
{avviso ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -9,21 +9,15 @@ import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
conteggioPartita,
|
||||
mioVoto,
|
||||
mvpPartita,
|
||||
ORE_ATTESA_MVP,
|
||||
useMieiVotiMvp,
|
||||
useVotaMvp,
|
||||
useVotiMvp,
|
||||
VOTI_MINIMI_MVP,
|
||||
votoMvpAperto,
|
||||
votoMvpChiuso,
|
||||
DURATA_VOTO_MVP_ORE,
|
||||
type VotoMvpPubblico,
|
||||
type VotoMvp,
|
||||
} from "@/lib/mvp-voti";
|
||||
|
||||
/**
|
||||
* Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
|
||||
* testa gli MVP sono più d'uno.
|
||||
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
|
||||
*
|
||||
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
|
||||
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
|
||||
@@ -34,30 +28,28 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiMvp();
|
||||
const miei = useMieiVotiMvp(io?.id);
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null);
|
||||
|
||||
const tutti: VotoMvpPubblico[] = voti.data ?? [];
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
const conteggio = conteggioPartita(tutti, matchId);
|
||||
const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
|
||||
const chiuso = votoMvpChiuso(evento.data, evento.ora);
|
||||
const mio = io ? mioVoto(tutti, matchId, io.id) : null;
|
||||
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
|
||||
const mvp = mvpPartita(tutti, matchId);
|
||||
const puoVotare = !!io && !mio && !chiuso && presente(io.id);
|
||||
const testa = conteggio[0];
|
||||
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
|
||||
const puoVotare = !!io && presente(io.id);
|
||||
|
||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Vote className="h-3.5 w-3.5" /> Voto MVP
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Apre {ORE_ATTESA_MVP} ore dopo l'inizio della partita.
|
||||
</p>
|
||||
</div>
|
||||
@@ -74,104 +66,74 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
votato_nome: nome,
|
||||
});
|
||||
setAperto(false);
|
||||
setScelto(null);
|
||||
toast.success(`Voto MVP registrato: ${nome}`);
|
||||
} catch {
|
||||
toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
|
||||
toast.error("Voto non riuscito, riprova");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-2xl bg-secondary/60 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
{mio || chiuso ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
Vota
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto((v) => !v)}
|
||||
disabled={!puoVotare}
|
||||
className="rounded-full bg-accent px-3 py-1 text-xs font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{mio ? "Cambia voto" : "Vota"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[13px]">
|
||||
{mvp.length > 0 ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
|
||||
{mvp.map((c) => (
|
||||
<span key={c.id} className="inline-flex items-center gap-1">
|
||||
<Crown className="h-3.5 w-3.5 text-warning" /> {c.nome} ({c.voti})
|
||||
</span>
|
||||
))}
|
||||
<p className="mt-2 text-xs">
|
||||
{testa && !pareggio ? (
|
||||
<span className="inline-flex items-center gap-1 font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{totale === 0
|
||||
? "Nessun voto: MVP da eleggere"
|
||||
: `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`}
|
||||
{totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{mio ? (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Hai votato {mio.votato_nome}: il voto non si può cambiare.
|
||||
</p>
|
||||
) : chiuso ? (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">Votazione chiusa.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
||||
) : !puoVotare ? (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Vota chi era presente a questa partita.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{aperto && !mio ? (
|
||||
<>
|
||||
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
|
||||
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
|
||||
{aperto ? (
|
||||
<div className="mt-3 grid max-h-60 grid-cols-2 gap-1.5 overflow-y-auto">
|
||||
{/* Sé stessi fuori dall'elenco, come nei badge social: l'auto-voto è vietato
|
||||
anche dal vincolo `mvp_no_autovoto` (M12). */}
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
scelto?.id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!scelto || vota.isPending}
|
||||
onClick={() => scelto && invia(scelto.id, scelto.nome)}
|
||||
className="mt-2 w-full rounded-full bg-accent px-3 py-2 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||
>
|
||||
{scelto ? `Conferma il voto per ${scelto.nome}` : "Scegli chi votare"}
|
||||
</button>
|
||||
<p className="mt-1 text-center text-[13px] text-muted-foreground">
|
||||
Il voto è definitivo: non si può cambiare.
|
||||
</p>
|
||||
</>
|
||||
{rosa
|
||||
.filter((g) => g.id !== io?.id)
|
||||
.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-card text-foreground",
|
||||
)}
|
||||
>
|
||||
{nomeCompleto(g)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : conteggio.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{conteggio.map((c) => (
|
||||
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-[13px] font-semibold">
|
||||
<span key={c.id} className="rounded-lg bg-card px-2 py-1 text-xs font-semibold">
|
||||
{c.nome} · {c.voti}
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-[13px] font-semibold text-muted-foreground">
|
||||
<div className="rounded-2xl bg-secondary/60 px-3 py-2 text-xs font-semibold text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent" />
|
||||
Hai votato {fatti}/{categorieSocial.length} categorie · un solo voto per categoria
|
||||
@@ -79,16 +79,14 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
{cat.emoji}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
{cat.nome}
|
||||
</span>
|
||||
<span className="block truncate text-[13px] text-muted-foreground">
|
||||
<span className="block truncate text-sm font-bold leading-tight">{cat.nome}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{mio ? `Hai votato ${mio.votato_nome}` : cat.descrizione}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
mio ? "bg-success text-success-foreground" : "bg-accent text-accent-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -107,7 +105,7 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
disabled={vota.isPending}
|
||||
onClick={() => invia(cat.id, g.id, nomeCompleto(g))}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
mio?.votato_id === g.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -120,17 +118,17 @@ export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
) : (
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
{totale === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nessun voto ancora: apri e scegli un compagno.
|
||||
</p>
|
||||
) : vincitore ? (
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-bold">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs font-bold">
|
||||
<Crown className="h-3.5 w-3.5 text-oro" />
|
||||
<Icon className="h-3.5 w-3.5 text-accent" />
|
||||
{vincitore.nome} · {vincitore.voti} {vincitore.voti === 1 ? "voto" : "voti"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Parità con {totale} voti: servono altri voti per assegnare il badge.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -107,13 +107,13 @@ function RigaNotifica({
|
||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||
if (arrivo < -60) onElimina(notifica.id);
|
||||
}}
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
|
||||
className="touch-pan-y bg-card p-3 pr-8 text-sm"
|
||||
>
|
||||
<p className="font-semibold">{notifica.titolo}</p>
|
||||
{notifica.corpo ? (
|
||||
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
|
||||
) : null}
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
|
||||
</motion.div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -254,7 +254,7 @@ export function PageHeader({
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
|
||||
{sottotitolo ? (
|
||||
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
|
||||
<p className="mt-1 truncate text-sm text-primary-foreground/80">{sottotitolo}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{azione ?? <LinkProfilo />}
|
||||
@@ -338,7 +338,7 @@ export function SezioneTendina({
|
||||
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
|
||||
*/
|
||||
export const classiInput =
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
|
||||
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||
|
||||
/**
|
||||
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
|
||||
@@ -349,7 +349,7 @@ export const classiInput =
|
||||
export function Campo({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block">{children}</span>
|
||||
@@ -378,7 +378,7 @@ export function StatoBadge({ stato, className }: { stato: Stato; className?: str
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
|
||||
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
meta.className,
|
||||
className,
|
||||
)}
|
||||
@@ -402,10 +402,10 @@ export function StatTile({
|
||||
<p className="font-display text-2xl leading-none">
|
||||
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mt-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
|
||||
{hint ? <p className="mt-0.5 text-xs text-accent">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const DrawerDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-[15px] text-muted-foreground", className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -145,7 +145,6 @@ export type Database = {
|
||||
data: string;
|
||||
id: string;
|
||||
luogo: string;
|
||||
link_maps: string | null;
|
||||
note: string;
|
||||
ora: string;
|
||||
pagelle_chiuse: boolean;
|
||||
@@ -161,7 +160,6 @@ export type Database = {
|
||||
data: string;
|
||||
id: string;
|
||||
luogo?: string;
|
||||
link_maps?: string | null;
|
||||
note?: string;
|
||||
ora?: string;
|
||||
pagelle_chiuse?: boolean;
|
||||
@@ -177,7 +175,6 @@ export type Database = {
|
||||
data?: string;
|
||||
id?: string;
|
||||
luogo?: string;
|
||||
link_maps?: string | null;
|
||||
note?: string;
|
||||
ora?: string;
|
||||
pagelle_chiuse?: boolean;
|
||||
@@ -536,7 +533,6 @@ export type Database = {
|
||||
parziali: Json;
|
||||
set_loro: number;
|
||||
set_nostri: number;
|
||||
valutazioni: Json;
|
||||
};
|
||||
Insert: {
|
||||
avversario: string;
|
||||
@@ -549,7 +545,6 @@ export type Database = {
|
||||
parziali?: Json;
|
||||
set_loro: number;
|
||||
set_nostri: number;
|
||||
valutazioni?: Json;
|
||||
};
|
||||
Update: {
|
||||
avversario?: string;
|
||||
@@ -562,7 +557,6 @@ export type Database = {
|
||||
parziali?: Json;
|
||||
set_loro?: number;
|
||||
set_nostri?: number;
|
||||
valutazioni?: Json;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
@@ -628,14 +622,7 @@ export type Database = {
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
mvp_voti_anonimi: {
|
||||
Row: {
|
||||
match_id: string | null;
|
||||
votato_id: string | null;
|
||||
votato_nome: string | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
has_role: {
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
Repeat,
|
||||
Lock,
|
||||
Ghost,
|
||||
Zap,
|
||||
Rocket,
|
||||
Anchor,
|
||||
Stethoscope,
|
||||
AlarmClock,
|
||||
ClipboardCheck,
|
||||
@@ -112,6 +114,15 @@ export const badgeDefs: BadgeDef[] = [
|
||||
soglie: { bronzo: 3, argento: 6, oro: 10 },
|
||||
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. */
|
||||
@@ -130,6 +141,18 @@ export const badgeSegreti: BadgeDef[] = [
|
||||
valore: (g) => (g.mvp >= 2 && g.votiPagella >= VOTI_MINIMI_PAGELLA && g.mediaVoto >= 8 ? 1 : 0),
|
||||
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",
|
||||
nome: "Cliente VIP dell'Infermeria",
|
||||
|
||||
@@ -34,6 +34,7 @@ export type Giocatore = {
|
||||
/** Serie consecutive per tipo: si azzerano in modo indipendente. */
|
||||
serieAllenamenti: number;
|
||||
seriePartite: number;
|
||||
serieConferme: number;
|
||||
/** MVP eletti dalla squadra. */
|
||||
mvp: number;
|
||||
/** Media delle pagelle ricevute dai compagni (1-10). */
|
||||
@@ -108,6 +109,7 @@ export const giocatori: Giocatore[] = rosaCSI
|
||||
streak: 0,
|
||||
serieAllenamenti: 0,
|
||||
seriePartite: 0,
|
||||
serieConferme: 0,
|
||||
mvp: 0,
|
||||
mediaVoto: 0,
|
||||
votiPagella: 0,
|
||||
|
||||
+9
-10
@@ -8,17 +8,16 @@ import type { RigaClassifica } from "./crapp-data";
|
||||
*/
|
||||
|
||||
export const CSI_BASE = "https://livescore.csibologna.it";
|
||||
/** Campionato Open Misto Eccellenza 2026/27. Cambia a ogni stagione. */
|
||||
export const CSI_PROJECT_ID = 933;
|
||||
/** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */
|
||||
export const CSI_PROJECT_ID = 767;
|
||||
/**
|
||||
* Coppa CSI della stagione in corso, `null` se la squadra non è iscritta (nel 2026/27 non c'è).
|
||||
* Stesso formato di tabella del campionato (girone all'italiana), solo con meno squadre:
|
||||
* `parseClassifica()` funziona invariata. Cambia a ogni stagione come CSI_PROJECT_ID, vedi
|
||||
* docs/modules/collegamento-csi.md.
|
||||
* Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone
|
||||
* all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a
|
||||
* ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md.
|
||||
*/
|
||||
export const CSI_COPPA_PROJECT_ID: number | null = null;
|
||||
/** C.R.A.P. Volley sul portale CSI: l'id cambia a ogni stagione (2025/26 era 3359). */
|
||||
export const CSI_TEAM_ID = 3878;
|
||||
export const CSI_COPPA_PROJECT_ID = 848;
|
||||
/** C.R.A.P. Volley sul portale CSI. */
|
||||
export const CSI_TEAM_ID = 3359;
|
||||
export const CSI_GIRONE = "Girone B";
|
||||
export const CSI_NOME_SQUADRA = "C.R.A.P. Volley";
|
||||
|
||||
@@ -134,7 +133,7 @@ export type DettaglioPartitaCsi = {
|
||||
|
||||
export type DatiCsi = {
|
||||
classifica: RigaClassifica[];
|
||||
/** Classifica del girone di Coppa (`CSI_COPPA_PROJECT_ID`). Vuota se il parsing fallisce. */
|
||||
/** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */
|
||||
classificaCoppa: RigaClassifica[];
|
||||
partite: PartitaCsi[];
|
||||
girone: string;
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||
|
||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||
export async function leggiEventi(): Promise<Evento[]> {
|
||||
|
||||
+3
-44
@@ -9,8 +9,6 @@ export type Evento = {
|
||||
tipo: EventoTipo;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
|
||||
linkMaps?: string | undefined;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string;
|
||||
@@ -32,7 +30,6 @@ export type RigaEvento = {
|
||||
tipo: string;
|
||||
titolo: string;
|
||||
luogo: string;
|
||||
link_maps?: string | null;
|
||||
data: string;
|
||||
ora: string;
|
||||
note: string | null;
|
||||
@@ -50,7 +47,6 @@ export function daRiga(r: RigaEvento): Evento {
|
||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
||||
titolo: r.titolo,
|
||||
luogo: r.luogo ?? "",
|
||||
linkMaps: r.link_maps ?? "",
|
||||
data: r.data,
|
||||
ora: r.ora ?? "",
|
||||
note: r.note ?? "",
|
||||
@@ -63,7 +59,7 @@ export function daRiga(r: RigaEvento): Evento {
|
||||
}
|
||||
|
||||
const COLONNE =
|
||||
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||
|
||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||
@@ -80,17 +76,6 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
|
||||
return { tipo: c, campionato: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Il link è accettato solo se punta a Google Maps (stesso criterio del CHECK su
|
||||
* `eventi_app.link_maps`): è un URL che gli utenti toccano, quindi niente `javascript:` né siti terzi.
|
||||
*/
|
||||
const LINK_MAPS =
|
||||
/^https:\/\/(maps\.app\.goo\.gl\/|goo\.gl\/maps\/|maps\.google\.[a-z.]+\/|(www\.)?google\.[a-z.]+\/maps)\S*$/;
|
||||
|
||||
export function linkMapsValido(url: string): boolean {
|
||||
return url.length <= 500 && LINK_MAPS.test(url);
|
||||
}
|
||||
|
||||
export const EVENTI_KEY = ["eventi"] as const;
|
||||
|
||||
async function fetchEventi(): Promise<Evento[]> {
|
||||
@@ -105,25 +90,6 @@ export function useEventi() {
|
||||
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) {
|
||||
const { eventi, ...resto } = useEventi();
|
||||
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
||||
@@ -139,7 +105,6 @@ export function eventoVuoto(): Evento {
|
||||
tipo: "allenamento",
|
||||
titolo: "",
|
||||
luogo: "",
|
||||
linkMaps: "",
|
||||
data: new Date().toISOString().slice(0, 10),
|
||||
ora: "20:30",
|
||||
note: "",
|
||||
@@ -160,7 +125,6 @@ export function useSalvaEvento() {
|
||||
tipo: evento.tipo,
|
||||
titolo: evento.titolo,
|
||||
luogo: evento.luogo,
|
||||
link_maps: evento.linkMaps?.trim() || null,
|
||||
data: evento.data,
|
||||
ora: evento.ora,
|
||||
note: evento.note,
|
||||
@@ -234,11 +198,6 @@ export function compleanniEventi(
|
||||
|
||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
||||
return rosaConvocata(rosa, evento?.convocati ?? []);
|
||||
}
|
||||
|
||||
/** 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));
|
||||
if (!evento || evento.convocati.length === 0) return rosa;
|
||||
return rosa.filter((g) => evento.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.
|
||||
* L'id (`g<N>`) non è generato dal database: va calcolato con `prossimoIdGiocatore`
|
||||
|
||||
+47
-79
@@ -8,91 +8,52 @@ export type VotoMvp = {
|
||||
votato_nome: string;
|
||||
};
|
||||
|
||||
/** Un voto come lo vede la squadra: senza votante (il voto è segreto). */
|
||||
export type VotoMvpPubblico = Pick<VotoMvp, "match_id" | "votato_id" | "votato_nome">;
|
||||
|
||||
const CHIAVE = ["mvp-voti"] as const;
|
||||
const chiaveMiei = (votanteId: string | undefined) => ["mvp-voti-miei", votanteId] as const;
|
||||
|
||||
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||
export const ORE_ATTESA_MVP = 2;
|
||||
|
||||
/** Per quante ore resta aperto il voto, dall'apertura (DD-052). */
|
||||
export const DURATA_VOTO_MVP_ORE = 24;
|
||||
|
||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita. */
|
||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
|
||||
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
|
||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||
if (Number.isNaN(inizio.getTime())) return false;
|
||||
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
|
||||
}
|
||||
|
||||
/** Il voto si è chiuso: sono passate `DURATA_VOTO_MVP_ORE` ore dall'apertura. */
|
||||
export function votoMvpChiuso(data: string, ora: string, adesso = new Date()): boolean {
|
||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||
if (Number.isNaN(inizio.getTime())) return false;
|
||||
const ore = ORE_ATTESA_MVP + DURATA_VOTO_MVP_ORE;
|
||||
return adesso.getTime() >= inizio.getTime() + ore * 60 * 60_000;
|
||||
}
|
||||
|
||||
/** Si può votare adesso: aperto e non ancora chiuso. */
|
||||
export function votoMvpInCorso(data: string, ora: string, adesso = new Date()): boolean {
|
||||
return votoMvpAperto(data, ora, adesso) && !votoMvpChiuso(data, ora, adesso);
|
||||
}
|
||||
|
||||
/** Tutti i voti MVP della squadra, senza chi li ha dati (poche righe, si carica tutto).
|
||||
* Vengono dalla vista `mvp_voti_anonimi`: la tabella è leggibile solo dal votante (DD-052).
|
||||
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
|
||||
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
||||
export function useVotiMvp() {
|
||||
return useQuery({
|
||||
queryKey: CHIAVE,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoMvpPubblico[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("mvp_voti_anonimi")
|
||||
.select("match_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoMvpPubblico[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** I voti che ho dato io: sono gli unici che la tabella mi lascia leggere. */
|
||||
export function useMieiVotiMvp(votanteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: chiaveMiei(votanteId),
|
||||
enabled: !!votanteId,
|
||||
staleTime: 10 * 60_000,
|
||||
queryFn: async (): Promise<VotoMvp[]> => {
|
||||
const { data, error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.select("match_id, votante_id, votato_id, votato_nome")
|
||||
.eq("votante_id", votanteId!);
|
||||
.select("match_id, votante_id, votato_id, votato_nome");
|
||||
if (error) throw error;
|
||||
return (data ?? []) as VotoMvp[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
|
||||
export function useVotaMvp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (voto: VotoMvp) => {
|
||||
const { error } = await supabase.from("mvp_voti").insert(voto);
|
||||
const { error } = await supabase
|
||||
.from("mvp_voti")
|
||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
||||
if (error) throw error;
|
||||
return voto;
|
||||
},
|
||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||
onSuccess: (voto) => {
|
||||
qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
|
||||
...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
|
||||
voto,
|
||||
]);
|
||||
qc.setQueryData<VotoMvpPubblico[]>(CHIAVE, (prec) => [
|
||||
...(prec ?? []),
|
||||
{ match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome },
|
||||
]);
|
||||
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => {
|
||||
const altri = (prec ?? []).filter(
|
||||
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id),
|
||||
);
|
||||
return [...altri, voto];
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -103,7 +64,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number };
|
||||
export const VOTI_MINIMI_MVP = 2;
|
||||
|
||||
/** Conteggio voti di una partita, dal più votato. */
|
||||
export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
|
||||
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
||||
const map = new Map<string, ConteggioMvp>();
|
||||
for (const v of voti) {
|
||||
if (v.match_id !== matchId) continue;
|
||||
@@ -114,29 +75,26 @@ export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): Cont
|
||||
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
|
||||
* merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
|
||||
*/
|
||||
export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
if (totaleVoti < VOTI_MINIMI_MVP) return [];
|
||||
return top.filter((c) => c.voti === top[0]!.voti);
|
||||
}
|
||||
|
||||
/** «Anna», «Anna e Bruno», «Anna, Bruno e Carla». */
|
||||
export function elencoNomi(nomi: string[]): string {
|
||||
if (nomi.length <= 1) return nomi.join("");
|
||||
return `${nomi.slice(0, -1).join(", ")} e ${nomi[nomi.length - 1]}`;
|
||||
}
|
||||
|
||||
/** MVP per ogni partita votata: matchId -> nome (più nomi uniti se c'è una parità in testa). */
|
||||
export function vincitoriMvp(voti: VotoMvpPubblico[]): Record<string, string> {
|
||||
/** Vincitore per ogni partita votata: matchId -> nome MVP. */
|
||||
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
||||
const perMatch = new Map<string, VotoMvp[]>();
|
||||
for (const v of voti) {
|
||||
const arr = perMatch.get(v.match_id) ?? [];
|
||||
arr.push(v);
|
||||
perMatch.set(v.match_id, arr);
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||
const mvp = mvpPartita(voti, matchId);
|
||||
if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
|
||||
for (const [matchId] of perMatch) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
// In caso di parità, o sotto il quorum minimo, nessun MVP assegnato.
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
out[matchId] = top[0]!.nome;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -146,14 +104,24 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
|
||||
* parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
|
||||
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
|
||||
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
|
||||
* restituisce una mappa vuota.
|
||||
*/
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
|
||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||
for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
|
||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
||||
for (const matchId of matchIds) {
|
||||
const top = conteggioPartita(voti, matchId);
|
||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
||||
if (
|
||||
totaleVoti >= VOTI_MINIMI_MVP &&
|
||||
top.length > 0 &&
|
||||
(top.length === 1 || top[0]!.voti > top[1]!.voti)
|
||||
) {
|
||||
const id = top[0]!.id;
|
||||
out[id] = (out[id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -24,10 +24,7 @@ export type TipoNotifica =
|
||||
| "sollecita_presenze_24h"
|
||||
| "sollecita_presenze_12h"
|
||||
| "sollecita_presenze_6h"
|
||||
| "sondaggio_cacche"
|
||||
| "compleanno"
|
||||
| "compleanno_auguri"
|
||||
| "presenza_modificata";
|
||||
| "sondaggio_cacche";
|
||||
|
||||
export type NotificaUtente = {
|
||||
id: string;
|
||||
|
||||
+17
-239
@@ -14,25 +14,6 @@ export type ObiettivoSquadra = {
|
||||
emoji: string;
|
||||
/** Frase breve che spiega come ogni giocatore può spostare l'ago. */
|
||||
impatto: string;
|
||||
/** Spiegazione completa mostrata nella card di dettaglio: nessun punto oscuro. */
|
||||
dettaglio: DettaglioObiettivo;
|
||||
};
|
||||
|
||||
export type DettaglioObiettivo = {
|
||||
/** Cosa misura l'obiettivo e come si arriva al numero mostrato, in parole semplici. */
|
||||
comeSiCalcola: string;
|
||||
/** Cosa fa salire il numero. */
|
||||
conta: string[];
|
||||
/** Cosa è una presenza persa: occupa un posto ma lo lascia vuoto, quindi abbassa la percentuale. */
|
||||
perse?: string[];
|
||||
/** Cosa resta fuori dal calcolo: non sale e non scende. */
|
||||
nonConta: string[];
|
||||
/** Quando l'obiettivo parte, quando si azzera o scade. */
|
||||
periodo: string;
|
||||
/** Da dove arrivano i dati e quando si aggiornano. */
|
||||
fonte: string;
|
||||
/** Esempio concreto con numeri. */
|
||||
esempio: string;
|
||||
};
|
||||
|
||||
export type ContestoObiettivi = {
|
||||
@@ -50,24 +31,6 @@ function meseCorrente(oggi: Date): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome" }).format(oggi).slice(0, 7);
|
||||
}
|
||||
|
||||
/** Istante corrente in Europe/Rome come "YYYY-MM-DD HH:mm", confrontabile con data + ora di un evento. */
|
||||
function adessoRoma(oggi: Date): string {
|
||||
const p = Object.fromEntries(
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Rome",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
.formatToParts(oggi)
|
||||
.map((x) => [x.type, x.value]),
|
||||
);
|
||||
return `${p["year"]}-${p["month"]}-${p["day"]} ${p["hour"]}:${p["minute"]}`;
|
||||
}
|
||||
|
||||
/** "a settembre" / "ad agosto": preposizione con elisione davanti a vocale. */
|
||||
function aMese(oggi: Date): string {
|
||||
const nome = new Intl.DateTimeFormat("it-IT", { timeZone: "Europe/Rome", month: "long" }).format(
|
||||
@@ -86,30 +49,16 @@ function fineMese(oggi: Date): string {
|
||||
return `${mese}-${String(ultimoGiorno).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenze (presente o ritardo) sul totale dei posti degli allenamenti e delle partite scelti da
|
||||
* `filtro`: per ogni evento i posti sono i convocati, o tutta la rosa se non ce ne sono. Chi non
|
||||
* è convocato non occupa un posto, quindi la sua assenza non pesa.
|
||||
*/
|
||||
function percentualePresenze(
|
||||
ctx: ContestoObiettivi,
|
||||
rosa: Giocatore[],
|
||||
filtro: (e: Evento) => boolean = () => true,
|
||||
) {
|
||||
let posti = 0;
|
||||
let presenti = 0;
|
||||
for (const e of ctx.eventi) {
|
||||
if (e.tipo !== "partita" && e.tipo !== "allenamento") continue;
|
||||
if (!filtro(e)) continue;
|
||||
const attesi = e.convocati.length === 0 ? rosa : rosa.filter((g) => e.convocati.includes(g.id));
|
||||
posti += attesi.length;
|
||||
function percentualePresenzeMese(ctx: ContestoObiettivi, rosaSize: number, mese: string) {
|
||||
const delMese = ctx.eventi.filter(
|
||||
(e) => e.data.startsWith(mese) && (e.tipo === "partita" || e.tipo === "allenamento"),
|
||||
);
|
||||
if (delMese.length === 0 || rosaSize === 0) return 0;
|
||||
const posti = delMese.length * rosaSize;
|
||||
const presenti = delMese.reduce((s, e) => {
|
||||
const risposte = ctx.presenze[e.id] ?? {};
|
||||
presenti += attesi.filter((g) => {
|
||||
const stato = risposte[g.id];
|
||||
return stato === "presente" || stato === "ritardo";
|
||||
}).length;
|
||||
}
|
||||
if (posti === 0) return 0;
|
||||
return s + Object.values(risposte).filter((x) => x === "presente" || x === "ritardo").length;
|
||||
}, 0);
|
||||
return Math.round((presenti / posti) * 100);
|
||||
}
|
||||
|
||||
@@ -130,6 +79,7 @@ export function obiettiviSquadra(
|
||||
ctx: ContestoObiettivi = contestoVuoto,
|
||||
oggi: Date = new Date(),
|
||||
): ObiettivoSquadra[] {
|
||||
const somma = (f: (g: Giocatore) => number) => rosa.reduce((s, g) => s + f(g), 0);
|
||||
const continui = rosa.filter((g) => g.serieAllenamenti >= 3).length;
|
||||
const vittorie = ctx.vittorie ?? 0;
|
||||
const mese = meseCorrente(oggi);
|
||||
@@ -138,35 +88,12 @@ export function obiettiviSquadra(
|
||||
id: "o1",
|
||||
titolo: `90% di presenze ${aMese(oggi)}`,
|
||||
descrizione: "Media presenze su partite e allenamenti del mese",
|
||||
valore: percentualePresenze(ctx, rosa, (e) => e.data.startsWith(mese)),
|
||||
valore: percentualePresenzeMese(ctx, rosa.length, mese),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
scadenza: fineMese(oggi),
|
||||
emoji: "📣",
|
||||
impatto: "Ogni sì in più alza la media di tutta la squadra.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si guardano solo le partite e gli allenamenti del mese in corso. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi del mese.",
|
||||
conta: [
|
||||
"«Presente» o «in ritardo» di un convocato a una partita o a un allenamento del mese.",
|
||||
"Vale anche per gli eventi futuri del mese: appena confermi, la percentuale sale.",
|
||||
],
|
||||
perse: [
|
||||
"«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
|
||||
"«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
|
||||
],
|
||||
nonConta: [
|
||||
"Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
|
||||
"Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
|
||||
"Gli eventi degli altri mesi.",
|
||||
],
|
||||
periodo:
|
||||
"Solo il mese in corso: si azzera il primo giorno di ogni mese e si chiude l'ultimo giorno del mese. Il mese dopo riparte da zero, con il suo titolo e la sua scadenza.",
|
||||
fonte:
|
||||
"Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
|
||||
esempio:
|
||||
"Ad agosto ci sono 2 allenamenti. Il primo ha 12 convocati e 10 rispondono «presente»: 10 posti pieni su 12. Il secondo ha 10 convocati e 9 «presenti»: 9 su 10. In tutto 19 posti pieni su 22, cioè 86%. I giocatori non convocati non sono nel conto: per arrivare al 90% servono almeno 20 posti pieni su 22.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o2",
|
||||
@@ -178,55 +105,16 @@ export function obiettiviSquadra(
|
||||
scadenza: fineMese(oggi),
|
||||
emoji: "⚡",
|
||||
impatto: "Bastano pochi tap per far quadrare i conti a chi organizza.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Per ogni evento in calendario ci sono tanti posti quanti sono i giocatori in rosa. Si contano le risposte date, di qualsiasi tipo, e si divide per tutti i posti.",
|
||||
conta: [
|
||||
"Qualsiasi risposta: presente, in ritardo o assente. Conta aver risposto, non cosa si è risposto.",
|
||||
"Partite, allenamenti ed eventi di squadra.",
|
||||
],
|
||||
nonConta: [
|
||||
"I compleanni: non richiedono risposta.",
|
||||
"Un evento a cui non hai ancora risposto.",
|
||||
],
|
||||
periodo:
|
||||
"Considera tutti gli eventi in programma, non solo quelli del mese. La scadenza indicata è la fine del mese corrente.",
|
||||
fonte: "Eventi del calendario e risposte dei giocatori. Si aggiorna a ogni risposta.",
|
||||
esempio:
|
||||
"Con 14 giocatori e 10 eventi i posti sono 140. Con 126 risposte date, siamo al 90%.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o7",
|
||||
titolo: "Presenze collettive",
|
||||
descrizione: "Presenze di tutta la rosa su allenamenti e partite definiti in stagione",
|
||||
valore: percentualePresenze(ctx, rosa),
|
||||
target: 90,
|
||||
unita: "%",
|
||||
titolo: "250 presenze complessive",
|
||||
descrizione: "Somma delle presenze di tutta la rosa in stagione",
|
||||
valore: somma((g) => g.presenze),
|
||||
target: 250,
|
||||
unita: "presenze",
|
||||
emoji: "🤝",
|
||||
impatto: "Ogni allenamento a cui vieni vale +1 per il gruppo.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si guardano tutte le partite e tutti gli allenamenti della stagione, passati e futuri. Per ognuno i posti sono i giocatori convocati (se nessuno è stato convocato, tutta la rosa). Un posto è pieno se il giocatore ha risposto «presente» o «in ritardo», vuoto in tutti gli altri casi. La percentuale è posti pieni ÷ posti totali, sommando tutti gli eventi insieme.",
|
||||
conta: [
|
||||
"«Presente» o «in ritardo» di un convocato a una partita o a un allenamento.",
|
||||
"Anche le conferme per gli eventi futuri: appena confermi, la percentuale sale.",
|
||||
],
|
||||
perse: [
|
||||
"«Assente» di un convocato: è una presenza persa, il posto resta vuoto.",
|
||||
"«Forse», «infortunato» o nessuna risposta di un convocato: finché non diventa «presente» o «in ritardo» il posto è vuoto.",
|
||||
],
|
||||
nonConta: [
|
||||
"Chi non è convocato a quell'evento: non occupa un posto, quindi la sua assenza non pesa.",
|
||||
"Cene, pizzate e altri eventi di squadra, e i compleanni: non sono partite né allenamenti e restano fuori da questa statistica.",
|
||||
],
|
||||
periodo:
|
||||
"Tutta la stagione: non si azzera mai e non ha scadenza. Conta tutto ciò che c'è in calendario, quindi un evento cancellato smette di contare e uno nuovo aggiunge i suoi posti.",
|
||||
fonte:
|
||||
"Calendario (eventi e convocati) e risposte di presenza dei giocatori. Si aggiorna ogni volta che qualcuno risponde o cambia risposta.",
|
||||
esempio:
|
||||
"In stagione ci sono 3 eventi: un allenamento con 12 convocati e 10 presenti, uno con 10 convocati e 9 presenti, una partita con 14 convocati e 13 presenti. Posti pieni 32 su 36, cioè 89%: per il 90% manca una presenza in più. La differenza con quello del mese è solo il periodo: qui contano tutti gli eventi insieme, senza azzerarsi.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o12",
|
||||
@@ -237,19 +125,6 @@ export function obiettiviSquadra(
|
||||
unita: "di media",
|
||||
emoji: "📝",
|
||||
impatto: "Prestazioni di gruppo: la media sale se giochiamo da squadra.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Media di tutti i voti dati dai giocatori nelle pagelle dopo le partite, arrotondata a una cifra decimale.",
|
||||
conta: ["Ogni voto dato in una pagella, a qualsiasi compagno."],
|
||||
nonConta: [
|
||||
"Gli autovoti: non si può votare se stessi.",
|
||||
"Le pagelle non compilate: non pesano sulla media.",
|
||||
],
|
||||
periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
|
||||
fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
|
||||
esempio:
|
||||
"Se i voti dati finora sono 7, 8 e 7, la media è 7.3: mancano 0.2 per arrivare a 7.5.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o13",
|
||||
@@ -260,16 +135,6 @@ export function obiettiviSquadra(
|
||||
unita: "voti",
|
||||
emoji: "🗳️",
|
||||
impatto: "Vota i compagni a fine partita: bastano due minuti.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si contano tutti i voti dati nelle pagelle durante la stagione. Un voto è un giocatore che valuta un compagno dopo una partita.",
|
||||
conta: ["Ogni singolo voto dato a un compagno, non ogni pagella intera."],
|
||||
nonConta: ["Le pagelle non compilate.", "Gli autovoti, che non sono ammessi."],
|
||||
periodo: "Tutta la stagione. Non si azzera e non ha scadenza.",
|
||||
fonte: "Pagelle compilate dai giocatori. Si aggiorna a ogni voto.",
|
||||
esempio:
|
||||
"Se dopo una partita 10 giocatori votano 9 compagni ciascuno, la squadra aggiunge 90 voti.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o11",
|
||||
@@ -280,21 +145,6 @@ export function obiettiviSquadra(
|
||||
unita: "giocatori",
|
||||
emoji: "🔗",
|
||||
impatto: "Tieni viva la tua serie e sblocchi anche questo.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si conta quanti giocatori hanno una serie di almeno 3 allenamenti consecutivi senza saltarne nemmeno uno.",
|
||||
conta: ["Un giocatore con 3 o più allenamenti di fila in cui era presente o in ritardo."],
|
||||
nonConta: [
|
||||
"Chi ha saltato un allenamento: la serie riparte da zero.",
|
||||
"Un allenamento passato senza risposta vale come assenza e azzera la serie.",
|
||||
"Le partite: la serie riguarda solo gli allenamenti.",
|
||||
"Un allenamento saltato per infortunio non la azzera: la serie resta ferma al valore di prima.",
|
||||
],
|
||||
periodo: "Si aggiorna di continuo sugli allenamenti passati. Non ha scadenza.",
|
||||
fonte: "Presenze agli allenamenti. Vedi anche la serie personale nel tuo profilo.",
|
||||
esempio:
|
||||
"Il target è 12 perché è il numero minimo per un allenamento 6 contro 6. Con 9 giocatori in serie ne mancano 3.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o3",
|
||||
@@ -305,22 +155,6 @@ export function obiettiviSquadra(
|
||||
unita: "vittorie",
|
||||
emoji: "🎉",
|
||||
impatto: "Una prestazione di gruppo e il primo passo è fatto.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con la prima vittoria.",
|
||||
conta: [
|
||||
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
|
||||
],
|
||||
nonConta: [
|
||||
"Le sconfitte e le partite non ancora giocate.",
|
||||
"Amichevoli e allenamenti.",
|
||||
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
|
||||
],
|
||||
periodo: "Tutta la stagione di campionato. Non si azzera.",
|
||||
fonte:
|
||||
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
|
||||
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o4",
|
||||
@@ -331,22 +165,6 @@ export function obiettiviSquadra(
|
||||
unita: "vittorie",
|
||||
emoji: "🔥",
|
||||
impatto: "Ogni vittoria ci avvicina ai playoff.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 5 vittorie.",
|
||||
conta: [
|
||||
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
|
||||
],
|
||||
nonConta: [
|
||||
"Le sconfitte e le partite non ancora giocate.",
|
||||
"Amichevoli e allenamenti.",
|
||||
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
|
||||
],
|
||||
periodo: "Tutta la stagione di campionato. Non si azzera.",
|
||||
fonte:
|
||||
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
|
||||
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o5",
|
||||
@@ -357,56 +175,16 @@ export function obiettiviSquadra(
|
||||
unita: "vittorie",
|
||||
emoji: "🏆",
|
||||
impatto: "L'obiettivo grande: serve tutta la stagione insieme.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si contano le partite di campionato vinte dalla squadra: vince chi si aggiudica più set. L'obiettivo si completa con 10 vittorie.",
|
||||
conta: [
|
||||
"Ogni partita ufficiale di campionato in cui abbiamo vinto più set degli avversari.",
|
||||
],
|
||||
nonConta: [
|
||||
"Le sconfitte e le partite non ancora giocate.",
|
||||
"Amichevoli e allenamenti.",
|
||||
"Le vittorie oltre il target: il contatore si ferma al traguardo.",
|
||||
],
|
||||
periodo: "Tutta la stagione di campionato. Non si azzera.",
|
||||
fonte:
|
||||
"Risultati ufficiali del portale CSI Bologna. Si aggiorna quando il portale pubblica il risultato, quindi può esserci un ritardo.",
|
||||
esempio: "Un 3-1 è una vittoria. Un 2-3 non lo è, anche se abbiamo vinto due set.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "o6",
|
||||
titolo: "1 evento di squadra al mese",
|
||||
descrizione: "Pizzate, cene e uscite fuori dal campo",
|
||||
// Conta solo quando l'ora dell'evento è arrivata, non quando viene definito.
|
||||
valore: ctx.eventi.filter(
|
||||
(e) =>
|
||||
e.tipo === "evento" &&
|
||||
e.data.startsWith(mese) &&
|
||||
`${e.data} ${e.ora || "00:00"}` <= adessoRoma(oggi),
|
||||
).length,
|
||||
valore: ctx.eventi.filter((e) => e.tipo === "evento" && e.data.startsWith(mese)).length,
|
||||
target: 1,
|
||||
unita: "eventi",
|
||||
emoji: "🍕",
|
||||
impatto: "Il gruppo si costruisce anche fuori dal campo.",
|
||||
dettaglio: {
|
||||
comeSiCalcola:
|
||||
"Si conta quanti eventi di squadra del mese corrente sono già iniziati. Basta uno per completare l'obiettivo.",
|
||||
conta: [
|
||||
"Pizzate, cene e uscite fuori dal campo inserite nel calendario come «evento».",
|
||||
"Solo quando l'ora dell'evento è arrivata. Se manca l'ora vale la mezzanotte.",
|
||||
],
|
||||
nonConta: [
|
||||
"Un evento ancora nel futuro: creato oggi per la settimana prossima non conta ancora.",
|
||||
"Partite e allenamenti.",
|
||||
"Eventi di altri mesi.",
|
||||
],
|
||||
periodo:
|
||||
"Si azzera il primo giorno di ogni mese. Il passaggio a completato compare al primo aggiornamento della pagina dopo l'ora dell'evento.",
|
||||
fonte: "Eventi del calendario.",
|
||||
esempio:
|
||||
"Una pizzata fissata il 20 alle 20:30: fino a quell'ora l'obiettivo resta 0/1, dopo diventa 1/1.",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+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. */
|
||||
function serieSu(
|
||||
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`
|
||||
* 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
|
||||
* risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta.
|
||||
*/
|
||||
|
||||
+16
-4
@@ -12,6 +12,7 @@ import { useEventi } from "./eventi";
|
||||
import {
|
||||
contaPartiteGiocate,
|
||||
contaPresenzeGiocatore,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
totaliEventiGiocatore,
|
||||
useRispostePresenze,
|
||||
@@ -34,7 +35,7 @@ function iniziali(nome: string, cognome: string): string {
|
||||
export function useAnagraficaRosa({
|
||||
conAllenatori = false,
|
||||
}: { conAllenatori?: boolean } = {}): Array<
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita"> & { cognome: string }
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||
> {
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
return useMemo(
|
||||
@@ -44,7 +45,6 @@ export function useAnagraficaRosa({
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
cognome: g.cognome,
|
||||
ruolo: g.ruolo,
|
||||
numero: g.numero,
|
||||
nascita: g.nascita ?? "",
|
||||
@@ -68,7 +68,7 @@ export function useRosa(): Giocatore[] {
|
||||
const { salvati: turniSalvati } = useTurniPalloni();
|
||||
const { infortuni, ritardi } = useInfortuniERitardi();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze: mappaPresenze } = useRispostePresenze();
|
||||
const { presenze: mappaPresenze, tempi } = useRispostePresenze();
|
||||
|
||||
const votiMvp = voti.data;
|
||||
|
||||
@@ -94,6 +94,7 @@ export function useRosa(): Giocatore[] {
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
@@ -104,7 +105,18 @@ export function useRosa(): Giocatore[] {
|
||||
infortuni: infortuni[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. */
|
||||
|
||||
+1
-33
@@ -1,11 +1,5 @@
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import {
|
||||
azioniMeta,
|
||||
contaValutazioni,
|
||||
fondamentali,
|
||||
totaliPerGiocatore,
|
||||
type ScoutMatch,
|
||||
} from "./scout-store";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
|
||||
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
|
||||
export function rigaCsv(campi: Array<string | number>) {
|
||||
@@ -40,32 +34,6 @@ export function csvScoutMatch(match: ScoutMatch, rosa: Giocatore[]): string {
|
||||
if (!t) continue;
|
||||
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
}
|
||||
if (match.valutazioni.length > 0) {
|
||||
righe.push("");
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
"Set",
|
||||
"Numero",
|
||||
"Giocatore",
|
||||
...fondamentali.flatMap((f) => [`${f.label} +`, `${f.label} -`]),
|
||||
]),
|
||||
);
|
||||
const sets = [...new Set(match.valutazioni.map((v) => v.set))].sort((x, y) => x - y);
|
||||
for (const set of sets) {
|
||||
for (const g of rosa) {
|
||||
if (!match.valutazioni.some((v) => v.set === set && v.giocatoreId === g.id)) continue;
|
||||
const c = contaValutazioni(match.valutazioni, g.id, set);
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
set,
|
||||
g.numero,
|
||||
g.nome,
|
||||
...fondamentali.flatMap((f) => [c[f.id].piu, c[f.id].meno]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
righe.push("");
|
||||
righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
|
||||
for (const a of match.azioni) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Azione, Valutazione } from "./scout-store";
|
||||
import type { Azione } from "./scout-store";
|
||||
|
||||
/** Stato condiviso di uno scout in corso: chi prende il controllo riparte da qui. */
|
||||
export type StatoScout = {
|
||||
azioni: Azione[];
|
||||
/** Assente negli stati salvati prima dell'introduzione delle valutazioni. */
|
||||
valutazioni?: Valutazione[];
|
||||
setChiusi: Array<[number, number]>;
|
||||
avversario: string;
|
||||
casa: boolean;
|
||||
@@ -14,7 +12,6 @@ export type StatoScout = {
|
||||
|
||||
export const statoIniziale = (avversario: string, casa: boolean): StatoScout => ({
|
||||
azioni: [],
|
||||
valutazioni: [],
|
||||
setChiusi: [],
|
||||
avversario,
|
||||
casa,
|
||||
|
||||
+1
-80
@@ -60,72 +60,6 @@ export type Azione = {
|
||||
ts: number;
|
||||
};
|
||||
|
||||
/** I fondamentali valutabili con − / + durante lo scout (non toccano il punteggio). */
|
||||
export type Fondamentale = "battuta" | "ricezione" | "attacco" | "muro";
|
||||
|
||||
export const fondamentali: Array<{ id: Fondamentale; label: string }> = [
|
||||
{ id: "battuta", label: "Battuta" },
|
||||
{ id: "ricezione", label: "Ricezione" },
|
||||
{ id: "attacco", label: "Attacco" },
|
||||
{ id: "muro", label: "Muro" },
|
||||
];
|
||||
|
||||
export type Valutazione = {
|
||||
id: string;
|
||||
giocatoreId: string;
|
||||
fondamentale: Fondamentale;
|
||||
segno: "+" | "-";
|
||||
set: number;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
export type ConteggioValutazioni = Record<Fondamentale, { piu: number; meno: number }>;
|
||||
|
||||
/** Conta i + e i − di un giocatore per fondamentale, in un set (o in tutti se `set` manca). */
|
||||
export function contaValutazioni(
|
||||
valutazioni: Valutazione[],
|
||||
giocatoreId: string,
|
||||
set?: number,
|
||||
): ConteggioValutazioni {
|
||||
const out = {} as ConteggioValutazioni;
|
||||
for (const f of fondamentali) out[f.id] = { piu: 0, meno: 0 };
|
||||
for (const v of valutazioni) {
|
||||
if (v.giocatoreId !== giocatoreId || (set !== undefined && v.set !== set)) continue;
|
||||
out[v.fondamentale][v.segno === "+" ? "piu" : "meno"] += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* I + di un giocatore in un set, esclusa la ricezione (che qui non vale come punto messo a
|
||||
* terra): quello che compare nel suo riquadro dello scout.
|
||||
*/
|
||||
export function contaPiuSenzaRicezione(
|
||||
valutazioni: Valutazione[],
|
||||
giocatoreId: string,
|
||||
set: number,
|
||||
): number {
|
||||
return valutazioni.filter(
|
||||
(v) =>
|
||||
v.giocatoreId === giocatoreId &&
|
||||
v.set === set &&
|
||||
v.segno === "+" &&
|
||||
v.fondamentale !== "ricezione",
|
||||
).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parziali da archiviare: i set chiusi più il set in corso, se ha qualcosa dentro (punti o
|
||||
* valutazioni). Senza punteggio un set vale 0-0: serve solo a numerarlo.
|
||||
*/
|
||||
export function parzialiFinali(
|
||||
setChiusi: Array<[number, number]>,
|
||||
corrente: [number, number],
|
||||
haDati: boolean,
|
||||
): Array<[number, number]> {
|
||||
return haDati ? [...setChiusi, corrente] : setChiusi;
|
||||
}
|
||||
|
||||
export type ScoutMatch = {
|
||||
id: string;
|
||||
data: string;
|
||||
@@ -135,7 +69,6 @@ export type ScoutMatch = {
|
||||
setLoro: number;
|
||||
parziali: Array<[number, number]>;
|
||||
azioni: Azione[];
|
||||
valutazioni: Valutazione[];
|
||||
};
|
||||
|
||||
type RigaScoutPartita = {
|
||||
@@ -147,7 +80,6 @@ type RigaScoutPartita = {
|
||||
set_loro: number;
|
||||
parziali: unknown;
|
||||
azioni: unknown;
|
||||
valutazioni: unknown;
|
||||
};
|
||||
|
||||
function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
@@ -160,7 +92,6 @@ function daRiga(r: RigaScoutPartita): ScoutMatch {
|
||||
setLoro: r.set_loro,
|
||||
parziali: (r.parziali as Array<[number, number]> | null) ?? [],
|
||||
azioni: (r.azioni as Azione[] | null) ?? [],
|
||||
valutazioni: (r.valutazioni as Valutazione[] | null) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,7 +100,7 @@ export const SCOUT_MATCHES_KEY = ["scout-partite"] as const;
|
||||
async function fetchScoutMatches(): Promise<ScoutMatch[]> {
|
||||
const { data, error } = await supabase
|
||||
.from("scout_partite")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni, valutazioni")
|
||||
.select("id, data, avversario, casa, set_nostri, set_loro, parziali, azioni")
|
||||
.order("creato_il", { ascending: false });
|
||||
if (error) throw error;
|
||||
return (data ?? []).map(daRiga);
|
||||
@@ -200,7 +131,6 @@ export function useSalvaScoutMatch() {
|
||||
set_loro: input.match.setLoro,
|
||||
parziali: JSON.parse(JSON.stringify(input.match.parziali)),
|
||||
azioni: JSON.parse(JSON.stringify(input.match.azioni)),
|
||||
valutazioni: JSON.parse(JSON.stringify(input.match.valutazioni)),
|
||||
});
|
||||
if (error) throw error;
|
||||
return input.match;
|
||||
@@ -229,15 +159,6 @@ export function useEliminaScoutMatch() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Una partita scoutata ha un risultato solo se ha registrato punti: quelle fatte di sole
|
||||
* valutazioni − / + hanno parziali 0-0 e non devono sostituire il referto ufficiale né
|
||||
* comparire come risultato in Home e Classifica.
|
||||
*/
|
||||
export function haPunteggio(match: Pick<ScoutMatch, "parziali">): boolean {
|
||||
return match.parziali.some(([n, l]) => n + l > 0);
|
||||
}
|
||||
|
||||
/** Somma delle azioni di un match per giocatore. */
|
||||
export function totaliPerGiocatore(azioni: Azione[]) {
|
||||
const out = new Map<string, { punti: number; ace: number; muri: number; errori: number }>();
|
||||
|
||||
+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 { Giocatore } from "./crapp-data";
|
||||
|
||||
export type SerieTipo = "allenamenti" | "partite";
|
||||
export type SerieTipo = "allenamenti" | "partite" | "conferme";
|
||||
|
||||
export type SerieDef = {
|
||||
tipo: SerieTipo;
|
||||
@@ -31,6 +31,14 @@ export const serieDefs: SerieDef[] = [
|
||||
traguardi: [2, 5, 8, 12],
|
||||
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 = {
|
||||
|
||||
@@ -27,13 +27,13 @@ function NotFoundComponent() {
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="font-display-lg text-7xl text-foreground">404</h1>
|
||||
<h2 className="mt-4 text-xl font-semibold text-foreground">Pagina non trovata</h2>
|
||||
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
La pagina che cerchi non esiste o è stata spostata.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
Torna alla home
|
||||
</Link>
|
||||
@@ -43,7 +43,7 @@ function NotFoundComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorComponent({ error, reset }: { error: unknown; reset: () => void }) {
|
||||
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
|
||||
console.error(error);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -53,7 +53,7 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
Questa pagina non si è caricata
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Qualcosa è andato storto da parte nostra. Puoi riprovare o tornare alla home.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-2">
|
||||
@@ -63,13 +63,13 @@ function ErrorComponent({ error, reset }: { error: unknown; reset: () => void })
|
||||
router.invalidate();
|
||||
reset();
|
||||
}}
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-[15px] font-semibold text-primary-foreground"
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground"
|
||||
>
|
||||
Riprova
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-[15px] font-semibold text-foreground"
|
||||
className="premi inline-flex min-h-11 items-center justify-center rounded-2xl border border-border bg-card px-4 py-2 text-sm font-semibold text-foreground"
|
||||
>
|
||||
Torna alla home
|
||||
</a>
|
||||
|
||||
+95
-24
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
BellOff,
|
||||
ChevronDown,
|
||||
@@ -32,9 +33,11 @@ import {
|
||||
useGiocatoriSquadra,
|
||||
useImpostaAttivo,
|
||||
useSalvaDatiSquadra,
|
||||
useSalvaTesseramento,
|
||||
useScollegaAccount,
|
||||
validaDatiSquadra,
|
||||
type DatiSquadra,
|
||||
type DatiTesseramento,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||
@@ -107,11 +110,18 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
const { righe } = useGiocatoriSquadra();
|
||||
const salvaSquadra = useSalvaDatiSquadra();
|
||||
const salvaProfilo = useSalvaProfilo();
|
||||
const salvaTesseramento = useSalvaTesseramento();
|
||||
const scollega = useScollegaAccount();
|
||||
const impostaAttivo = useImpostaAttivo();
|
||||
|
||||
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
const [tesseramento, setTesseramento] = useState<DatiTesseramento | null>(null);
|
||||
|
||||
const tesseramentoCorrente: DatiTesseramento = tesseramento ?? {
|
||||
numeroTessera: g.numeroTessera,
|
||||
dataTessera: g.dataTessera,
|
||||
};
|
||||
|
||||
const squadraCorrente: DatiSquadra = datiSquadra ?? {
|
||||
nome: g.nome,
|
||||
@@ -121,7 +131,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
email: g.email,
|
||||
tipo: g.tipo,
|
||||
};
|
||||
// L'allenatore non ha numero né ruolo in campo (DD-034).
|
||||
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
|
||||
const allenatore = isAllenatore(g);
|
||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||
|
||||
@@ -157,6 +167,16 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
}
|
||||
}
|
||||
|
||||
async function confermaTesseramento() {
|
||||
try {
|
||||
await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente });
|
||||
setTesseramento(null);
|
||||
toast.success("Tesseramento aggiornato");
|
||||
} catch (e) {
|
||||
toast.error(messaggioErrore(e, "Salvataggio non riuscito"));
|
||||
}
|
||||
}
|
||||
|
||||
async function confermaProfilo() {
|
||||
try {
|
||||
await salvaProfilo.mutateAsync(profiloCorrente);
|
||||
@@ -184,7 +204,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border pt-3">
|
||||
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati squadra</h3>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
@@ -244,14 +264,54 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
type="button"
|
||||
onClick={confermaSquadra}
|
||||
disabled={!datiSquadra || salvaSquadra.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva dati squadra
|
||||
</button>
|
||||
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Numero tessera">
|
||||
<input
|
||||
value={tesseramentoCorrente.numeroTessera ?? ""}
|
||||
maxLength={40}
|
||||
placeholder="Non ancora tesserato"
|
||||
onChange={(e) =>
|
||||
setTesseramento({
|
||||
...tesseramentoCorrente,
|
||||
numeroTessera: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data tessera">
|
||||
<input
|
||||
type="date"
|
||||
value={tesseramentoCorrente.dataTessera ?? ""}
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaTesseramento}
|
||||
disabled={!tesseramento || salvaTesseramento.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva tesseramento
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allenatore ? (
|
||||
<>
|
||||
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati personali</h3>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
|
||||
<CampiAllenatore
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
@@ -268,12 +328,12 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
type="button"
|
||||
onClick={confermaProfilo}
|
||||
disabled={!bozza || salvaProfilo.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva dati personali
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-[13px]">
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
|
||||
<span className="min-w-0 text-muted-foreground">
|
||||
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
||||
</span>
|
||||
@@ -344,7 +404,7 @@ function Documento({
|
||||
onClick={scarica}
|
||||
disabled={!path || inCorso}
|
||||
className={cn(
|
||||
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase disabled:opacity-60",
|
||||
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase disabled:opacity-60",
|
||||
statoClasse[stato],
|
||||
)}
|
||||
>
|
||||
@@ -382,12 +442,12 @@ function SchedaGiocatore({
|
||||
return (
|
||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-[15px] tabular-nums">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||
{allenatore ? "All" : numero}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold leading-tight">{nome}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ruoloVisibile(g)} · profilo {perc}%
|
||||
</p>
|
||||
</div>
|
||||
@@ -426,6 +486,15 @@ function SchedaGiocatore({
|
||||
stato={certificato}
|
||||
path={profilo?.certificatoPath ?? null}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
statoClasse[g.numeroTessera ? "presente" : "assente"],
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -480,7 +549,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto(true)}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
||||
</button>
|
||||
@@ -489,7 +558,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
||||
<h3 className="font-display text-[15px] uppercase tracking-wide">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">
|
||||
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
||||
</h3>
|
||||
<Campo label="Tipo">
|
||||
@@ -563,7 +632,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
setAperto(false);
|
||||
setDati(datiVuoti);
|
||||
}}
|
||||
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-[13px] font-bold uppercase"
|
||||
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
@@ -571,7 +640,7 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
type="button"
|
||||
onClick={() => void conferma()}
|
||||
disabled={aggiungi.isPending}
|
||||
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Aggiungi
|
||||
</button>
|
||||
@@ -598,7 +667,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -606,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
||||
type="button"
|
||||
onClick={() => void riattiva()}
|
||||
disabled={impostaAttivo.isPending}
|
||||
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-[13px] font-bold text-success-foreground disabled:opacity-50"
|
||||
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
||||
</button>
|
||||
@@ -653,7 +722,7 @@ function Dashboard() {
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
|
||||
<div className="px-5 pt-4">
|
||||
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
Riservata agli amministratori della squadra.
|
||||
</p>
|
||||
@@ -674,6 +743,7 @@ function Dashboard() {
|
||||
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
|
||||
"valido",
|
||||
).length;
|
||||
const tesserati = attivi.filter((g) => g.numeroTessera).length;
|
||||
|
||||
const contenutoSquadra = (
|
||||
<>
|
||||
@@ -685,11 +755,12 @@ function Dashboard() {
|
||||
label="Certificati validi"
|
||||
hint="non scaduti"
|
||||
/>
|
||||
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||
</button>
|
||||
@@ -698,7 +769,7 @@ function Dashboard() {
|
||||
);
|
||||
|
||||
const contenutoProfili = isPending ? (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
) : (
|
||||
@@ -715,7 +786,7 @@ function Dashboard() {
|
||||
/>
|
||||
))}
|
||||
{allenatori.length > 0 ? (
|
||||
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
|
||||
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
|
||||
) : null}
|
||||
{allenatori.map((g, i) => (
|
||||
<SchedaGiocatore
|
||||
@@ -748,7 +819,7 @@ function Dashboard() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||
</button>
|
||||
@@ -764,7 +835,7 @@ function Dashboard() {
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
|
||||
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
|
||||
!attiva && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -784,7 +855,7 @@ function Dashboard() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
);
|
||||
@@ -838,7 +909,7 @@ function Dashboard() {
|
||||
type="button"
|
||||
onClick={inviaMessaggio}
|
||||
disabled={inviaNotifica.isPending || !messaggio.trim()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inviaNotifica.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -850,7 +921,7 @@ function Dashboard() {
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
|
||||
@@ -43,9 +43,7 @@ function AllenamentoDetail() {
|
||||
if (!evento) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-[15px] text-muted-foreground">
|
||||
Allenamento non trovato
|
||||
</p>
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Allenamento non trovato</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,39 +59,29 @@ function AllenamentoDetail() {
|
||||
<Dumbbell className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Allenamento di squadra
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> {evento.ora}
|
||||
</span>
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||
{evento.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||
</div>
|
||||
@@ -103,12 +91,7 @@ function AllenamentoDetail() {
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze
|
||||
eventoId={evento.id}
|
||||
data={evento.data}
|
||||
tipo={evento.tipo}
|
||||
convocati={evento.convocati}
|
||||
/>
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -33,9 +33,7 @@ async function leggiCsi(): Promise<DatiCsi> {
|
||||
scarica(urlClassifica()),
|
||||
// Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico
|
||||
// come classifica/partite del girone (vedi il controllo sotto).
|
||||
CSI_COPPA_PROJECT_ID === null
|
||||
? Promise.resolve("")
|
||||
: scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
|
||||
scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""),
|
||||
scarica(urlPartite()),
|
||||
]);
|
||||
const classifica = parseClassifica(html);
|
||||
|
||||
@@ -107,34 +107,34 @@ function Benvenuto() {
|
||||
|
||||
{!pronta ? null : !utenteId ? (
|
||||
<>
|
||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Accedi con il tuo account Google per collegare il profilo giocatore.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={accedi}
|
||||
disabled={inCorso}
|
||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
||||
>
|
||||
<LogIn className="h-4 w-4" /> Accedi con Google
|
||||
</button>
|
||||
</>
|
||||
) : erroreCollegamento ? (
|
||||
<>
|
||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Nessun profilo trovato per la tua email. Contatta un amministratore per collegare il tuo
|
||||
account.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void esciERiprova()}
|
||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-[15px] font-bold uppercase shadow-card"
|
||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-secondary py-3.5 text-sm font-bold uppercase shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> Esci
|
||||
</button>
|
||||
</>
|
||||
) : inAttesaCollegamento ? (
|
||||
<p className="mt-2 text-center text-[15px] text-muted-foreground">
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Ti stiamo collegando al tuo profilo...
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
+14
-24
@@ -8,8 +8,7 @@ import { molla, proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { compleanniEventi, type Evento } from "@/lib/eventi";
|
||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
@@ -57,7 +56,7 @@ function Calendario() {
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi } = useEventiVisibili();
|
||||
const { eventi } = useEventi();
|
||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `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).
|
||||
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) : [];
|
||||
// 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 (
|
||||
<>
|
||||
@@ -127,7 +119,7 @@ function Calendario() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||
{giorniIT.map((g, i) => (
|
||||
<span key={i}>{g}</span>
|
||||
))}
|
||||
@@ -200,7 +192,7 @@ function Calendario() {
|
||||
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
|
||||
style={sfondo ? { backgroundImage: sfondo } : undefined}
|
||||
className={cn(
|
||||
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold",
|
||||
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold",
|
||||
tipo === "partita" && "bg-accent text-accent-foreground",
|
||||
tipo === "allenamento" && "bg-training text-training-foreground",
|
||||
tipo === "evento" && "bg-warning text-warning-foreground",
|
||||
@@ -232,10 +224,10 @@ function Calendario() {
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Scorri a destra o sinistra per cambiare mese.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs font-semibold text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
|
||||
</span>
|
||||
@@ -256,7 +248,7 @@ function Calendario() {
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[14px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
|
||||
</Link>
|
||||
@@ -271,22 +263,20 @@ function Calendario() {
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun evento in programma
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Prossimi compleanni">
|
||||
<Section titolo="Compleanni">
|
||||
<div className="space-y-3">
|
||||
{prossimiCompleanni.length > 0 ? (
|
||||
prossimiCompleanni.map((c) => <EventoCard key={c.id} evento={c} />)
|
||||
{compleanniMese.length > 0 ? (
|
||||
compleanniMese.map((c) => <EventoCard key={c.id} evento={c} />)
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
{compleanniMeseCorrente.length > 0
|
||||
? "Nessun altro compleanno questo mese"
|
||||
: "Nessun compleanno questo mese"}
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun compleanno in {mesiIT[mese]!.toLowerCase()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -310,7 +300,7 @@ function Calendario() {
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun evento in questa data
|
||||
</p>
|
||||
)}
|
||||
|
||||
+17
-17
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { formatData, type RigaClassifica } from "@/lib/crapp-data";
|
||||
import { PageHeader, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import {
|
||||
isNostraSquadra,
|
||||
@@ -82,7 +82,7 @@ export const Route = createFileRoute("/classifica")({
|
||||
function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-[13px] font-bold uppercase text-muted-foreground">
|
||||
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<span>#</span>
|
||||
<span>Squadra</span>
|
||||
<span className="text-center">G</span>
|
||||
@@ -90,7 +90,7 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
|
||||
<span className="text-center">Pt</span>
|
||||
</div>
|
||||
{righe.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">{vuoto}</p>
|
||||
<p className="px-3 py-4 text-center text-xs text-muted-foreground">{vuoto}</p>
|
||||
) : (
|
||||
righe.map((r) => {
|
||||
const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley";
|
||||
@@ -98,14 +98,14 @@ function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: s
|
||||
<div
|
||||
key={r.pos}
|
||||
className={cn(
|
||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-[15px] last:border-0",
|
||||
"grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] items-center gap-2 border-b border-border px-3 py-2.5 text-sm last:border-0",
|
||||
noi && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<span className={cn("font-display text-base", noi && "text-accent")}>{r.pos}</span>
|
||||
<span className={cn("truncate", noi ? "font-bold" : "font-medium")}>{r.squadra}</span>
|
||||
<span className="text-center text-[13px] text-muted-foreground">{r.giocate}</span>
|
||||
<span className="text-center text-[13px] tabular-nums text-muted-foreground">
|
||||
<span className="text-center text-xs text-muted-foreground">{r.giocate}</span>
|
||||
<span className="text-center text-xs tabular-nums text-muted-foreground">
|
||||
{r.setFatti}:{r.setSubiti}
|
||||
</span>
|
||||
<span className="text-center font-bold tabular-nums">{r.punti}</span>
|
||||
@@ -150,7 +150,7 @@ function Classifica() {
|
||||
const m = matchDaPartitaCsi(p);
|
||||
return { ...m, mvp: mvpPerData(m.data), scout: false };
|
||||
})
|
||||
: scoutMatches.filter(haPunteggio).map((m) => ({
|
||||
: scoutMatches.map((m) => ({
|
||||
id: m.id,
|
||||
data: m.data,
|
||||
avversario: m.avversario,
|
||||
@@ -171,7 +171,7 @@ function Classifica() {
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-[13px]",
|
||||
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
|
||||
csi?.formatoSospetto
|
||||
? "bg-warning/15 text-warning"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
@@ -190,9 +190,9 @@ function Classifica() {
|
||||
</div>
|
||||
{classificaCoppa.length > 0 ? (
|
||||
<>
|
||||
<h3 className="mb-2 px-1 text-[15px] font-bold text-foreground">Coppa</h3>
|
||||
<h3 className="mb-2 px-1 text-sm font-bold text-foreground">Coppa</h3>
|
||||
<TabellaClassifica righe={classificaCoppa} vuoto="Classifica non disponibile." />
|
||||
<h3 className="mb-2 mt-4 px-1 text-[15px] font-bold text-foreground">Girone</h3>
|
||||
<h3 className="mb-2 mt-4 px-1 text-sm font-bold text-foreground">Girone</h3>
|
||||
</>
|
||||
) : null}
|
||||
<TabellaClassifica righe={classifica} vuoto="Classifica non ancora disponibile." />
|
||||
@@ -204,14 +204,14 @@ function Classifica() {
|
||||
const contenutoStorico = useMemo(
|
||||
() =>
|
||||
tuttiMatch.length === 0 ? (
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-[13px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-center text-xs text-muted-foreground shadow-card">
|
||||
Nessuna partita disponibile.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{raggruppaPerStagione(tuttiMatch).map(({ stagione, match }) => (
|
||||
<section key={stagione} className="space-y-3">
|
||||
<h3 className="px-1 text-[15px] font-bold text-foreground">Stagione {stagione}</h3>
|
||||
<h3 className="px-1 text-sm font-bold text-foreground">Stagione {stagione}</h3>
|
||||
{match.map((m) => {
|
||||
const vinta = m.setNostri > m.setLoro;
|
||||
const eventoId = eventoIdPerData.get(m.data);
|
||||
@@ -237,7 +237,7 @@ function Classifica() {
|
||||
logoAvversario={m.logoAvversario}
|
||||
avversario={m.avversario}
|
||||
/>
|
||||
<p className="truncate text-[15px] font-bold">{casa.nome}</p>
|
||||
<p className="truncate text-sm font-bold">{casa.nome}</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<LogoPartita
|
||||
@@ -245,9 +245,9 @@ function Classifica() {
|
||||
logoAvversario={m.logoAvversario}
|
||||
avversario={m.avversario}
|
||||
/>
|
||||
<p className="truncate text-[15px] font-bold">{trasferta.nome}</p>
|
||||
<p className="truncate text-sm font-bold">{trasferta.nome}</p>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatData(m.data)} · MVP {m.mvp || "da votare"}
|
||||
{m.scout ? " · scoutata" : ""}
|
||||
</p>
|
||||
@@ -278,7 +278,7 @@ function Classifica() {
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"rounded-lg px-2 py-1 text-[13px] font-semibold tabular-nums",
|
||||
"rounded-lg px-2 py-1 text-xs font-semibold tabular-nums",
|
||||
setVintoDaNoi ? "bg-secondary" : "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -287,7 +287,7 @@ function Classifica() {
|
||||
);
|
||||
})}
|
||||
{eventoId && !m.mvp ? (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-[13px] font-bold uppercase text-accent">
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 text-xs font-bold uppercase text-accent">
|
||||
Vota MVP <ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
+27
-52
@@ -1,4 +1,3 @@
|
||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||
@@ -22,8 +21,8 @@ import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
eventoVuoto,
|
||||
linkMapsValido,
|
||||
useEliminaEvento,
|
||||
useEventi,
|
||||
useSalvaEvento,
|
||||
type CategoriaEvento,
|
||||
type Evento,
|
||||
@@ -66,7 +65,7 @@ function GestioneEventi() {
|
||||
const io = useGiocatoreBase();
|
||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
@@ -92,7 +91,7 @@ function GestioneEventi() {
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
<div className="px-5">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Solo i referenti della squadra possono creare o modificare gli eventi.
|
||||
</p>
|
||||
</div>
|
||||
@@ -112,12 +111,6 @@ function GestioneEventi() {
|
||||
toast.error("Serve un titolo per l'evento");
|
||||
return;
|
||||
}
|
||||
if ((bozza.linkMaps ?? "").trim() && !linkMapsValido(bozza.linkMaps!.trim())) {
|
||||
toast.error(
|
||||
"Il link deve essere di Google Maps (https://maps.app.goo.gl/… o google.com/maps…)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (modificaEsistente) {
|
||||
setConfermaModifica(true);
|
||||
return;
|
||||
@@ -128,11 +121,7 @@ function GestioneEventi() {
|
||||
async function conferma() {
|
||||
if (!bozza) return;
|
||||
try {
|
||||
await salva.mutateAsync({
|
||||
...bozza,
|
||||
titolo: bozza.titolo.trim(),
|
||||
linkMaps: (bozza.linkMaps ?? "").trim(),
|
||||
});
|
||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
||||
toast.success("Evento salvato");
|
||||
setBozza(null);
|
||||
} catch {
|
||||
@@ -218,7 +207,7 @@ function GestioneEventi() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-[13px] font-bold text-muted-foreground">
|
||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||
{giorniIT.map((g, i) => (
|
||||
<span key={i}>{g}</span>
|
||||
))}
|
||||
@@ -259,7 +248,7 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={() => apriGiorno(giorno)}
|
||||
className={cn(
|
||||
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold transition-transform active:scale-90",
|
||||
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
|
||||
haEventi
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -276,11 +265,11 @@ function GestioneEventi() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<p aria-busy="true" className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
|
||||
Carico gli eventi…
|
||||
</p>
|
||||
) : isError ? (
|
||||
<div className="mt-3 space-y-2 text-center text-[15px]">
|
||||
<div className="mt-3 space-y-2 text-center text-sm">
|
||||
<p className="text-destructive">
|
||||
Non sono riuscito a caricare gli eventi
|
||||
{error instanceof Error ? `: ${error.message}` : ""}.
|
||||
@@ -288,13 +277,13 @@ function GestioneEventi() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="rounded-2xl bg-secondary px-4 py-2 text-[13px] font-bold uppercase"
|
||||
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
|
||||
>
|
||||
Riprova
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
|
||||
modificare o eliminare.
|
||||
</p>
|
||||
@@ -314,7 +303,7 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={() => aggiorna(daCategoria(t.id))}
|
||||
className={cn(
|
||||
"rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
|
||||
"rounded-full py-2 text-xs font-bold uppercase transition-colors",
|
||||
categoriaEvento(bozza) === t.id
|
||||
? "bg-card shadow-card text-foreground"
|
||||
: "text-muted-foreground",
|
||||
@@ -348,17 +337,15 @@ function GestioneEventi() {
|
||||
</Campo>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Giorno
|
||||
</span>
|
||||
<div className="mt-1 flex h-10 items-center justify-between gap-1">
|
||||
<span className="truncate text-[15px] font-semibold">
|
||||
{formatData(bozza.data)}
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCambiaGiorno(true)}
|
||||
className="shrink-0 rounded-full px-2 py-1 text-[13px] font-bold uppercase text-accent active:scale-95"
|
||||
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
|
||||
>
|
||||
Cambia
|
||||
</button>
|
||||
@@ -384,18 +371,6 @@ function GestioneEventi() {
|
||||
/>
|
||||
</Campo>
|
||||
|
||||
<Campo label="Link Google Maps (facoltativo)">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={bozza.linkMaps ?? ""}
|
||||
maxLength={500}
|
||||
placeholder="Incolla il link da Condividi → Copia link"
|
||||
onChange={(e) => aggiorna({ linkMaps: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
|
||||
<Campo label="Note">
|
||||
<textarea
|
||||
value={bozza.note}
|
||||
@@ -419,7 +394,7 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={() => aggiorna({ casa: o.casa })}
|
||||
className={cn(
|
||||
"flex-1 rounded-full py-2 text-[13px] font-bold uppercase transition-colors",
|
||||
"flex-1 rounded-full py-2 text-xs font-bold uppercase transition-colors",
|
||||
bozza.casa === o.casa
|
||||
? "bg-card shadow-card text-foreground"
|
||||
: "text-muted-foreground",
|
||||
@@ -458,7 +433,7 @@ function GestioneEventi() {
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||
"truncate rounded-xl px-2.5 py-2 text-left text-xs font-semibold transition-colors",
|
||||
scelto
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
@@ -475,7 +450,7 @@ function GestioneEventi() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBozza(null)}
|
||||
className="flex-1 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||
className="flex-1 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
@@ -483,7 +458,7 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={chiediConferma}
|
||||
disabled={salva.isPending}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Salva
|
||||
</button>
|
||||
@@ -514,8 +489,8 @@ function GestioneEventi() {
|
||||
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-bold leading-tight">{e.titolo}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{e.ora} · {e.luogo || "luogo da definire"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -538,7 +513,7 @@ function GestioneEventi() {
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun evento in programma
|
||||
</p>
|
||||
)}
|
||||
@@ -546,7 +521,7 @@ function GestioneEventi() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={nuovoNelGiorno}
|
||||
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||
</button>
|
||||
@@ -569,14 +544,14 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={conferma}
|
||||
disabled={salva.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
|
||||
</button>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
@@ -598,14 +573,14 @@ function GestioneEventi() {
|
||||
type="button"
|
||||
onClick={() => daEliminare && rimuovi(daEliminare.id)}
|
||||
disabled={elimina.isPending}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-[15px] font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-destructive py-3 text-sm font-bold uppercase text-destructive-foreground disabled:opacity-50"
|
||||
>
|
||||
{elimina.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Elimina
|
||||
</button>
|
||||
<DrawerClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
|
||||
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
@@ -631,7 +606,7 @@ function Interruttore({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-[13px] font-bold uppercase transition-colors",
|
||||
"rounded-full px-3 py-1.5 text-xs font-bold uppercase transition-colors",
|
||||
attivo ? "bg-accent text-accent-foreground" : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,7 @@ function EventoDetail() {
|
||||
if (!evento) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-[15px] text-muted-foreground">Evento non trovato</p>
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,39 +58,29 @@ function EventoDetail() {
|
||||
<PartyPopper className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Evento extra-campo
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> {evento.ora}
|
||||
</span>
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||
{evento.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||
</div>
|
||||
@@ -98,12 +88,7 @@ function EventoDetail() {
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze
|
||||
eventoId={evento.id}
|
||||
data={evento.data}
|
||||
tipo={evento.tipo}
|
||||
convocati={evento.convocati}
|
||||
/>
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+32
-50
@@ -9,17 +9,15 @@ import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import { eConvocato, type Evento } from "@/lib/eventi";
|
||||
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||
import { useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useRispostePresenze } from "@/lib/presenze";
|
||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { haPunteggio, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
import { useVotiMvp, vincitoriMvp } from "@/lib/mvp-voti";
|
||||
import { mediaVotoColpoDOcchio } from "@/lib/pagelle";
|
||||
|
||||
@@ -48,14 +46,14 @@ function Index() {
|
||||
const base = useGiocatoreBase();
|
||||
const giocatore = useIo();
|
||||
const allenatore = isAllenatore(base);
|
||||
const { eventi } = useEventiVisibili();
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
||||
const prossimo = prossimi[0] ?? null;
|
||||
const daConfermare = prossimi
|
||||
.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 { data: csi } = useCsi();
|
||||
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||
@@ -63,9 +61,7 @@ function Index() {
|
||||
const votiMvp = useVotiMvp();
|
||||
const mvpPerMatch = vincitoriMvp(votiMvp.data ?? []);
|
||||
const csiGiocate = csi ? partiteGiocate(csi.partite) : [];
|
||||
const ultimaBase = csiGiocate[0]
|
||||
? matchDaPartitaCsi(csiGiocate[0])
|
||||
: (scoutMatches.find(haPunteggio) ?? null);
|
||||
const ultimaBase = csiGiocate[0] ? matchDaPartitaCsi(csiGiocate[0]) : (scoutMatches[0] ?? null);
|
||||
const obiettivi = useObiettivi();
|
||||
const obiettivo = obiettivi.find((o) => progressoObiettivo(o) < 100) ?? obiettivi[0] ?? null;
|
||||
const eventoUltima = ultimaBase
|
||||
@@ -89,7 +85,7 @@ function Index() {
|
||||
className="h-14 w-14 rounded-full shadow-pop ring-2 ring-primary-foreground/25"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||
Ciao {nome.split(" ")[0]}
|
||||
</p>
|
||||
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
||||
@@ -103,13 +99,13 @@ function Index() {
|
||||
>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
|
||||
<p className="text-[13px] uppercase text-primary-foreground/80">In classifica</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">
|
||||
{noi ? `${noi.vinte}-${noi.perse}` : "—"}
|
||||
</p>
|
||||
<p className="text-[13px] uppercase text-primary-foreground/80">Bilancio W-L</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
|
||||
</div>
|
||||
{giocatore ? (
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
@@ -117,7 +113,7 @@ function Index() {
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-[13px] uppercase text-primary-foreground/80">Streak</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -135,7 +131,7 @@ function Index() {
|
||||
azione={
|
||||
<Link
|
||||
to="/calendario"
|
||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Calendario <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -144,7 +140,7 @@ function Index() {
|
||||
{prossimo ? (
|
||||
<EventoCard evento={prossimo} {...(linkProssimo ? { linkTo: linkProssimo } : {})} />
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nessun impegno in programma.
|
||||
</p>
|
||||
)}
|
||||
@@ -159,7 +155,7 @@ function Index() {
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nient'altro da confermare: sei in pari.
|
||||
</p>
|
||||
)}
|
||||
@@ -174,7 +170,7 @@ function Index() {
|
||||
<Link
|
||||
to="/classifica"
|
||||
search={{ tab: "storico" }}
|
||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Storico <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -186,10 +182,8 @@ function Index() {
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[15px] font-bold">
|
||||
CRAP Volley vs {ultima.avversario}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="truncate text-sm font-bold">CRAP Volley vs {ultima.avversario}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ultima.casa ? "In casa" : "Trasferta"}
|
||||
{ultima.mvp ? ` · MVP ${ultima.mvp}` : " · MVP da votare"}
|
||||
</p>
|
||||
@@ -202,7 +196,7 @@ function Index() {
|
||||
{ultima.parziali.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
@@ -223,7 +217,7 @@ function Index() {
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-[13px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nessun risultato disponibile.
|
||||
</p>
|
||||
)}
|
||||
@@ -236,39 +230,27 @@ function Index() {
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-[15px] font-semibold text-accent"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<ObiettivoDrawer obiettivo={obiettivo}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Dettaglio obiettivo: ${obiettivo.titolo}`}
|
||||
className="block w-full text-left"
|
||||
>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-[15px] font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span>{" "}
|
||||
{obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
|
||||
Come funziona <ChevronRight className="h-3.5 w-3.5" />
|
||||
</p>
|
||||
</Card>
|
||||
</button>
|
||||
</ObiettivoDrawer>
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,7 @@ function PartitaCsiDetail() {
|
||||
if (!partita) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function PartitaCsiDetail() {
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{partita.casa ? "In casa" : "Fuori casa"}
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{partita.avversario}</p>
|
||||
@@ -93,7 +93,7 @@ function PartitaCsiDetail() {
|
||||
{partita.parziali.map(([noi, loro], i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-[13px] font-semibold tabular-nums"
|
||||
className="rounded-lg bg-secondary px-2 py-1 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{noi}-{loro}
|
||||
</span>
|
||||
|
||||
+34
-52
@@ -11,7 +11,7 @@ import { DettaglioCsiEsteso, LogoSquadra, MetaPartitaCsi } from "@/components/cr
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { haPunteggio, useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
@@ -60,7 +60,7 @@ function PartitaDetail() {
|
||||
if (!evento) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-[15px] text-muted-foreground">Partita non trovata</p>
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Partita non trovata</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -70,26 +70,25 @@ function PartitaDetail() {
|
||||
const csiMatch = csi
|
||||
? partiteGiocate(csi.partite).find((p) => p.data === evento.data)
|
||||
: undefined;
|
||||
const match =
|
||||
scout && haPunteggio(scout)
|
||||
? {
|
||||
id: scout.id,
|
||||
data: scout.data,
|
||||
avversario: scout.avversario,
|
||||
logoAvversario: "",
|
||||
casa: scout.casa,
|
||||
setNostri: scout.setNostri,
|
||||
setLoro: scout.setLoro,
|
||||
parziali: scout.parziali,
|
||||
campo: "",
|
||||
girone: "",
|
||||
numeroGara: "",
|
||||
arbitro: "",
|
||||
link: "",
|
||||
}
|
||||
: csiMatch
|
||||
? matchDaPartitaCsi(csiMatch)
|
||||
: undefined;
|
||||
const match = scout
|
||||
? {
|
||||
id: scout.id,
|
||||
data: scout.data,
|
||||
avversario: scout.avversario,
|
||||
logoAvversario: "",
|
||||
casa: scout.casa,
|
||||
setNostri: scout.setNostri,
|
||||
setLoro: scout.setLoro,
|
||||
parziali: scout.parziali,
|
||||
campo: "",
|
||||
girone: "",
|
||||
numeroGara: "",
|
||||
arbitro: "",
|
||||
link: "",
|
||||
}
|
||||
: csiMatch
|
||||
? matchDaPartitaCsi(csiMatch)
|
||||
: undefined;
|
||||
const totaliTeam = scout ? totaliSquadra([scout]) : null;
|
||||
const avversario = evento.titolo.includes(" vs ")
|
||||
? (evento.titolo.split(" vs ").find((p) => !p.includes("CRAP")) ?? evento.titolo)
|
||||
@@ -119,7 +118,7 @@ function PartitaDetail() {
|
||||
<LogoSquadra src={match.logoAvversario} alt={avversario} className="h-10 w-10" />
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{casa ? "In casa" : "Fuori casa"}
|
||||
{evento.campionato ? " · Campionato" : " · Amichevole"}
|
||||
</p>
|
||||
@@ -127,32 +126,22 @@ function PartitaDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-[15px] text-muted-foreground">
|
||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> {evento.ora}
|
||||
</span>
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
{evento.linkMaps ? (
|
||||
<a
|
||||
href={evento.linkMaps}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-4 inline-flex items-center gap-2 font-semibold text-accent underline"
|
||||
>
|
||||
<MapPin className="h-4 w-4" /> Apri su Maps
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-[15px]">
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||
{evento.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-semibold">
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocati.length}
|
||||
</div>
|
||||
@@ -174,7 +163,7 @@ function PartitaDetail() {
|
||||
<Section titolo="Risultato">
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[15px] font-bold">CRAP Volley</span>
|
||||
<span className="text-sm font-bold">CRAP Volley</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 text-lg font-display font-bold",
|
||||
@@ -185,7 +174,7 @@ function PartitaDetail() {
|
||||
>
|
||||
{match.setNostri} - {match.setLoro}
|
||||
</span>
|
||||
<span className="text-right text-[15px] font-bold">{avversario}</span>
|
||||
<span className="text-right text-sm font-bold">{avversario}</span>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{match.parziali.map(([noi, loro], i) => (
|
||||
@@ -194,9 +183,7 @@ function PartitaDetail() {
|
||||
className="flex items-center justify-between rounded-xl bg-secondary px-3 py-2"
|
||||
>
|
||||
<span className="font-display text-lg">{noi}</span>
|
||||
<span className="text-[13px] font-semibold text-muted-foreground">
|
||||
Set {i + 1}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">Set {i + 1}</span>
|
||||
<span className="font-display text-lg">{loro}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -226,7 +213,7 @@ function PartitaDetail() {
|
||||
)
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
La partita non è ancora stata disputata. Torna qui dopo il fischio finale per vedere il
|
||||
risultato.
|
||||
</p>
|
||||
@@ -261,13 +248,13 @@ function PartitaDetail() {
|
||||
].map((t) => (
|
||||
<div key={t.l} className="rounded-2xl bg-secondary p-2.5">
|
||||
<p className="font-display text-xl leading-none">{t.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Dettaglio giocatori (uso interno allenatori)
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
@@ -277,7 +264,7 @@ function PartitaDetail() {
|
||||
return (
|
||||
<div
|
||||
key={gid}
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-[13px]"
|
||||
className="flex items-center gap-2 rounded-xl bg-secondary/60 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-semibold">
|
||||
#{g.numero} {g.nome}
|
||||
@@ -298,7 +285,7 @@ function PartitaDetail() {
|
||||
csvScoutMatch(scout, rosa),
|
||||
)
|
||||
}
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
|
||||
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV
|
||||
</button>
|
||||
@@ -308,12 +295,7 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze
|
||||
eventoId={evento.id}
|
||||
data={evento.data}
|
||||
tipo={evento.tipo}
|
||||
convocati={evento.convocati}
|
||||
/>
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+10
-10
@@ -220,7 +220,7 @@ function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-[13px] font-bold uppercase tracking-wide"
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
@@ -312,11 +312,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-[13px] text-muted-foreground">
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
@@ -339,11 +339,11 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
type="button"
|
||||
onClick={cambiaEmail}
|
||||
disabled={!email.pronta || email.imposta.isPending}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-[15px] disabled:opacity-60"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Email</span>
|
||||
<span className="block text-[13px] text-muted-foreground">
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{email.attive
|
||||
? "Le notifiche arrivano anche via email"
|
||||
: "Nessuna email, solo notifiche in app"}
|
||||
@@ -363,7 +363,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -373,7 +373,7 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -382,17 +382,17 @@ function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-[15px] transition-colors hover:bg-accent/5"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-[13px] text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-[15px] font-bold text-destructive shadow-card"
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
|
||||
+177
-143
@@ -3,7 +3,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select } from "@/components/crapp/ui-bits";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
@@ -27,15 +27,10 @@ import {
|
||||
} from "@/lib/scout-live";
|
||||
import {
|
||||
azioniMeta,
|
||||
contaPiuSenzaRicezione,
|
||||
contaValutazioni,
|
||||
parzialiFinali,
|
||||
fondamentali,
|
||||
useSalvaScoutMatch,
|
||||
totaliPerGiocatore,
|
||||
type Azione,
|
||||
type AzioneTipo,
|
||||
type Fondamentale,
|
||||
type Valutazione,
|
||||
} from "@/lib/scout-store";
|
||||
|
||||
export const Route = createFileRoute("/scout")({
|
||||
@@ -57,6 +52,8 @@ export const Route = createFileRoute("/scout")({
|
||||
component: Scout,
|
||||
});
|
||||
|
||||
const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"];
|
||||
|
||||
function Blocco({
|
||||
icona,
|
||||
titolo,
|
||||
@@ -75,7 +72,7 @@ function Blocco({
|
||||
{icona}
|
||||
</div>
|
||||
<h1 className="mt-4 font-display text-2xl uppercase leading-none">{titolo}</h1>
|
||||
<p className="mt-2 text-[15px] text-muted-foreground">{testo}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{testo}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,11 +119,7 @@ function Scout() {
|
||||
|
||||
const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null;
|
||||
const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
const ripresa =
|
||||
!!statoSalvato.data &&
|
||||
(statoSalvato.data.azioni.length > 0 ||
|
||||
(statoSalvato.data.valutazioni?.length ?? 0) > 0 ||
|
||||
statoSalvato.data.setChiusi.length > 0);
|
||||
const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0;
|
||||
|
||||
if (!controllo) {
|
||||
return (
|
||||
@@ -145,7 +138,7 @@ function Scout() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sessione.refetch()}
|
||||
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||
className="mt-5 w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
Aggiorna
|
||||
</button>
|
||||
@@ -163,7 +156,7 @@ function Scout() {
|
||||
if (ok) setControllo(true);
|
||||
else toast.error("Un altro compagno ha appena preso lo scout");
|
||||
}}
|
||||
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="mt-5 w-full rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{ripresa ? "Riprendi lo scout" : "Prendi il controllo"}
|
||||
</button>
|
||||
@@ -203,12 +196,10 @@ function ScoutBoard({
|
||||
.trim() || "Avversario",
|
||||
partita.titolo.trim().toLowerCase().startsWith("crap"),
|
||||
);
|
||||
const [avversario] = useState(base.avversario);
|
||||
const [casa] = useState(base.casa);
|
||||
const [avversario, setAvversario] = useState(base.avversario);
|
||||
const [casa, setCasa] = useState(base.casa);
|
||||
const [setChiusi, setSetChiusi] = useState<Array<[number, number]>>(base.setChiusi);
|
||||
const [azioni, setAzioni] = useState<Azione[]>(base.azioni);
|
||||
const [valutazioni, setValutazioni] = useState<Valutazione[]>(base.valutazioni ?? []);
|
||||
const [ordine, setOrdine] = useState<"cognome" | "numero">("cognome");
|
||||
const [selezionato, setSelezionato] = useState<string | null>(null);
|
||||
const salva = useSalvaStatoScout();
|
||||
const cancella = useCancellaStatoScout();
|
||||
@@ -217,73 +208,52 @@ function ScoutBoard({
|
||||
// Solo id/nome/numero servono (selezione e display): niente statistiche di squadra.
|
||||
const rosa = useAnagraficaRosa();
|
||||
const finito = useRef(false);
|
||||
const codaSalvataggio = useRef<Promise<unknown>>(Promise.resolve());
|
||||
|
||||
/** In campo solo chi ha confermato la presenza (anche in ritardo). */
|
||||
const convocati = useMemo(() => {
|
||||
const presenti = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
);
|
||||
const elenco = presenti.length > 0 ? presenti : rosa;
|
||||
return [...elenco].sort((a, b) =>
|
||||
ordine === "numero"
|
||||
? a.numero - b.numero
|
||||
: a.cognome.localeCompare(b.cognome, "it") || a.numero - b.numero,
|
||||
);
|
||||
}, [risposte, rosa, ordine]);
|
||||
return presenti.length > 0 ? presenti : rosa;
|
||||
}, [risposte, rosa]);
|
||||
|
||||
// Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui.
|
||||
useEffect(() => {
|
||||
if (finito.current) return;
|
||||
// Subito a ogni tocco, non dopo una pausa: così non resta nulla in sospeso se l'app si
|
||||
// chiude. Il salvataggio è sempre lo stato intero, quindi uno riuscito dopo uno fallito
|
||||
// rimette a posto tutto; la coda li tiene in ordine.
|
||||
const stato = { azioni, valutazioni, setChiusi, avversario, casa };
|
||||
codaSalvataggio.current = codaSalvataggio.current
|
||||
.then(() => salva.mutateAsync({ eventoId: partita.id, stato }))
|
||||
.then(() => {
|
||||
toast.dismiss("scout-salvataggio");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Salvataggio non riuscito: controlla la connessione", {
|
||||
id: "scout-salvataggio",
|
||||
duration: Infinity,
|
||||
});
|
||||
const id = window.setTimeout(() => {
|
||||
void salva.mutateAsync({
|
||||
eventoId: partita.id,
|
||||
stato: { azioni, setChiusi, avversario, casa },
|
||||
});
|
||||
}, 800);
|
||||
return () => window.clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [azioni, valutazioni, setChiusi, avversario, casa, partita.id]);
|
||||
}, [azioni, setChiusi, avversario, casa, partita.id]);
|
||||
|
||||
const setCorrente = setChiusi.length + 1;
|
||||
const azioniSet = azioni.filter((a) => a.set === setCorrente);
|
||||
const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length;
|
||||
const puntiLoro = azioniSet.length - puntiNoi;
|
||||
const setHaDati = azioniSet.length > 0 || valutazioni.some((v) => v.set === setCorrente);
|
||||
const setNostri = setChiusi.filter(([n, l]) => n > l).length;
|
||||
const setLoro = setChiusi.length - setNostri;
|
||||
const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]);
|
||||
|
||||
function valuta(fondamentale: Fondamentale, segno: Valutazione["segno"]) {
|
||||
if (!selezionato) {
|
||||
function registra(tipo: AzioneTipo) {
|
||||
const meta = azioniMeta[tipo];
|
||||
if (meta.richiedeGiocatore && !selezionato) {
|
||||
toast.error("Seleziona prima il giocatore");
|
||||
return;
|
||||
}
|
||||
setValutazioni((prev) => [
|
||||
...prev,
|
||||
{
|
||||
setAzioni((prev) => {
|
||||
const nuova: Azione = {
|
||||
id: `${Date.now()}-${prev.length}`,
|
||||
giocatoreId: selezionato,
|
||||
fondamentale,
|
||||
segno,
|
||||
tipo,
|
||||
set: setCorrente,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function annullaValutazione() {
|
||||
const ultima = valutazioni[valutazioni.length - 1];
|
||||
if (!ultima || ultima.set !== setCorrente) {
|
||||
toast.error("Puoi annullare solo le valutazioni del set in corso");
|
||||
return;
|
||||
}
|
||||
setValutazioni((prev) => prev.slice(0, -1));
|
||||
...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}),
|
||||
};
|
||||
return [...prev, nuova];
|
||||
});
|
||||
}
|
||||
|
||||
function annulla() {
|
||||
@@ -296,16 +266,17 @@ function ScoutBoard({
|
||||
}
|
||||
|
||||
function chiudiSet() {
|
||||
if (!setHaDati) {
|
||||
toast.error("Set senza valutazioni");
|
||||
if (puntiNoi === 0 && puntiLoro === 0) {
|
||||
toast.error("Set senza azioni");
|
||||
return;
|
||||
}
|
||||
setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]);
|
||||
toast.success(`Set ${setCorrente} chiuso`);
|
||||
toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`);
|
||||
}
|
||||
|
||||
async function finePartita() {
|
||||
const parziali = parzialiFinali(setChiusi, [puntiNoi, puntiLoro], setHaDati);
|
||||
const parziali: Array<[number, number]> =
|
||||
puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi;
|
||||
if (parziali.length === 0) {
|
||||
toast.error("Nessun set da salvare");
|
||||
return;
|
||||
@@ -323,29 +294,26 @@ function ScoutBoard({
|
||||
setLoro: parziali.length - vinti,
|
||||
parziali,
|
||||
azioni,
|
||||
valutazioni,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error("Salvataggio non riuscito, riprova");
|
||||
return;
|
||||
}
|
||||
toast.success("Partita salvata");
|
||||
toast.success("Partita salvata: ora la squadra può votare l'MVP");
|
||||
finito.current = true;
|
||||
void cancella.mutateAsync(partita.id);
|
||||
onFine();
|
||||
navigate({ to: "/squadra" });
|
||||
}
|
||||
|
||||
const giocatoreSel = rosa.find((g) => g.id === selezionato);
|
||||
const contiSelezionato = selezionato
|
||||
? contaValutazioni(valutazioni, selezionato, setCorrente)
|
||||
: null;
|
||||
const ultime = [...azioni].slice(-4).reverse();
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-30 bg-hero px-5 pb-4 pt-6 text-primary-foreground">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-[13px] font-bold uppercase tracking-wide">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary-foreground/10 px-2.5 py-1 text-xs font-bold uppercase tracking-wide">
|
||||
<Radio className="h-3 w-3 text-accent" /> Scout live · Set {setCorrente}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -353,41 +321,58 @@ function ScoutBoard({
|
||||
type="button"
|
||||
onClick={annulla}
|
||||
disabled={azioniSet.length === 0}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold disabled:opacity-40"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFine}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-[13px] font-bold"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-foreground/10 px-3 py-1.5 text-xs font-bold"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" /> Rilascia
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2 text-center">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs uppercase text-primary-foreground/60">CRAP Volley</p>
|
||||
<p className="font-display text-5xl leading-none text-accent">{puntiNoi}</p>
|
||||
</div>
|
||||
<p className="font-display text-2xl leading-none text-primary-foreground/50">
|
||||
{setNostri}-{setLoro}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs uppercase text-primary-foreground/60">
|
||||
{avversario || "Avversario"}
|
||||
</p>
|
||||
<p className="font-display text-5xl leading-none">{puntiLoro}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{setChiusi.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1.5">
|
||||
{setChiusi.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-lg bg-primary-foreground/10 px-2 py-0.5 text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{p[0]}-{p[1]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Selezione giocatore
|
||||
</p>
|
||||
<div className="w-40 shrink-0">
|
||||
<Select
|
||||
value={ordine}
|
||||
onChange={(e) => setOrdine(e.target.value as "cognome" | "numero")}
|
||||
aria-label="Ordina i giocatori"
|
||||
>
|
||||
<option value="cognome">Per cognome</option>
|
||||
<option value="numero">Per numero</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
1. Tocca il giocatore
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{convocati.map((g) => {
|
||||
const attivo = selezionato === g.id;
|
||||
const piu = contaPiuSenzaRicezione(valutazioni, g.id, setCorrente);
|
||||
const t = totali.get(g.id);
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
@@ -399,14 +384,14 @@ function ScoutBoard({
|
||||
)}
|
||||
>
|
||||
<p className="font-display text-2xl leading-none">{g.numero}</p>
|
||||
<p className="mt-1 text-[13px] font-bold leading-tight">{g.nome}</p>
|
||||
<p className="mt-1 text-xs font-bold leading-tight">{g.nome}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[13px] tabular-nums",
|
||||
"text-xs tabular-nums",
|
||||
attivo ? "text-accent-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
+{piu}
|
||||
{t ? `${t.punti}p` : "0p"}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
@@ -415,63 +400,112 @@ function ScoutBoard({
|
||||
</section>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Valuta{giocatoreSel ? ` ${giocatoreSel.cognome} - ${giocatoreSel.numero}` : ""} · Set{" "}
|
||||
{setCorrente}
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
2. Tocca l'azione
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ordineAzioni.map((tipo) => (
|
||||
<button
|
||||
key={tipo}
|
||||
type="button"
|
||||
onClick={() => registra(tipo)}
|
||||
className={cn(
|
||||
"rounded-2xl py-4 font-display text-xl uppercase tracking-wide shadow-card transition-transform active:scale-95",
|
||||
azioniMeta[tipo].className,
|
||||
!selezionato && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{azioniMeta[tipo].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => (
|
||||
<button
|
||||
key={tipo}
|
||||
type="button"
|
||||
onClick={() => registra(tipo)}
|
||||
className={cn(
|
||||
"rounded-2xl py-3 text-sm font-bold uppercase shadow-card transition-transform active:scale-95",
|
||||
azioniMeta[tipo].className,
|
||||
)}
|
||||
>
|
||||
{azioniMeta[tipo].short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Ultime azioni
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={annullaValutazione}
|
||||
disabled={!valutazioni.some((v) => v.set === setCorrente)}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-secondary px-3 py-1.5 text-[13px] font-bold disabled:opacity-40"
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" /> Annulla
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-2 rounded-3xl bg-card p-3 shadow-card",
|
||||
!selezionato && "opacity-60",
|
||||
{ultime.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-muted-foreground">Nessuna azione registrata.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{ultime.map((a, i) => {
|
||||
const g = rosa.find((x) => x.id === a.giocatoreId);
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-1 text-xs",
|
||||
i === 0 && "anim-riga",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{g ? `#${g.numero} ${g.nome}` : "Avversario"}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-xs font-bold uppercase",
|
||||
azioniMeta[a.tipo].className,
|
||||
)}
|
||||
>
|
||||
{azioniMeta[a.tipo].short}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
{!selezionato ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Tocca un giocatore: i + e i − non cambiano il punteggio.
|
||||
</p>
|
||||
) : null}
|
||||
{fondamentali.map((f) => {
|
||||
const c = contiSelezionato?.[f.id] ?? { piu: 0, meno: 0 };
|
||||
return (
|
||||
<div key={f.id} className="grid grid-cols-[1fr_auto_auto] items-center gap-2">
|
||||
<span className="text-[15px] font-bold">{f.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${f.label}: meno`}
|
||||
onClick={() => valuta(f.id, "-")}
|
||||
className="min-w-16 rounded-2xl bg-destructive px-4 py-2.5 font-display text-xl leading-none text-destructive-foreground shadow-card transition-transform active:scale-95"
|
||||
>
|
||||
− <span className="tabular-nums">{c.meno}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${f.label}: più`}
|
||||
onClick={() => valuta(f.id, "+")}
|
||||
className="min-w-16 rounded-2xl bg-success px-4 py-2.5 font-display text-xl leading-none text-success-foreground shadow-card transition-transform active:scale-95"
|
||||
>
|
||||
+ <span className="tabular-nums">{c.piu}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-5 pt-4">
|
||||
<Card>
|
||||
<label className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Avversario
|
||||
</label>
|
||||
<input
|
||||
value={avversario}
|
||||
maxLength={40}
|
||||
onChange={(e) => setAvversario(e.target.value)}
|
||||
className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="mt-3 flex rounded-full bg-secondary p-1">
|
||||
{[true, false].map((v) => (
|
||||
<button
|
||||
key={String(v)}
|
||||
type="button"
|
||||
onClick={() => setCasa(v)}
|
||||
className={cn(
|
||||
"flex-1 rounded-full py-1.5 text-xs font-bold uppercase",
|
||||
casa === v ? "bg-card text-foreground shadow-card" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{v ? "In casa" : "Trasferta"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-2 px-5 pb-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={chiudiSet}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" /> Chiudi set
|
||||
</button>
|
||||
@@ -479,7 +513,7 @@ function ScoutBoard({
|
||||
type="button"
|
||||
onClick={finePartita}
|
||||
disabled={salvaMatch.isPending}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" /> Fine partita
|
||||
</button>
|
||||
|
||||
+56
-73
@@ -1,12 +1,11 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
|
||||
import { Cake, ChevronDown, Crown, Trophy } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { formatData, inizialiDa } from "@/lib/crapp-data";
|
||||
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import {
|
||||
useRosa,
|
||||
@@ -51,7 +50,7 @@ import {
|
||||
|
||||
function RuoloBadge({ ruolo }: { ruolo: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide text-accent-foreground">
|
||||
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-accent-foreground">
|
||||
{ruolo}
|
||||
</span>
|
||||
);
|
||||
@@ -147,7 +146,7 @@ function Squadra() {
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{nomeCompleto(a)}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
@@ -164,7 +163,7 @@ function Squadra() {
|
||||
|
||||
{isOpen && a.nascita ? (
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -192,12 +191,10 @@ function Squadra() {
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[15px] font-bold leading-tight">
|
||||
{g.nome}
|
||||
</span>
|
||||
<span className="block truncate text-sm font-bold leading-tight">{g.nome}</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={g.ruolo} />
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{g.numero ? `#${g.numero}` : "n° da definire"}
|
||||
</span>
|
||||
</span>
|
||||
@@ -232,7 +229,7 @@ function Squadra() {
|
||||
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||
{g.nascita ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
||||
{g.nascita.slice(0, 4)}
|
||||
</p>
|
||||
@@ -246,11 +243,13 @@ function Squadra() {
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
...(sonoAllenatore ? [] : [{ l: "Cacche 💩", v: g.cacchePartita || "—" }]),
|
||||
...(sonoAllenatore
|
||||
? []
|
||||
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
||||
<p className="mt-1 text-xs font-semibold uppercase text-muted-foreground">
|
||||
{s.l}
|
||||
</p>
|
||||
</div>
|
||||
@@ -259,11 +258,11 @@ function Squadra() {
|
||||
|
||||
{sonoAllenatore ? null : (
|
||||
<>
|
||||
<p className="mt-4 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-[13px] text-muted-foreground">
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
@@ -275,20 +274,17 @@ function Squadra() {
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-[13px] font-bold leading-tight">
|
||||
<p className="mt-1 text-xs font-bold leading-tight">
|
||||
{b.def.nome}
|
||||
</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-[13px] font-bold uppercase",
|
||||
meta.text,
|
||||
)}
|
||||
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
@@ -330,8 +326,8 @@ function Squadra() {
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
|
||||
>
|
||||
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
|
||||
<span className="shrink-0 text-[15px] text-foreground/80">Classifica per</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
|
||||
<span className="shrink-0 text-[14px] text-foreground/80">Classifica per</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right text-[14px] font-bold">
|
||||
{criteri.find((c) => c.id === criterio)?.label}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
@@ -349,7 +345,7 @@ function Squadra() {
|
||||
type="button"
|
||||
onClick={() => setCriterio(c.id)}
|
||||
className={cn(
|
||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-[15px] font-bold",
|
||||
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-sm font-bold",
|
||||
criterio === c.id
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-secondary text-foreground",
|
||||
@@ -372,13 +368,13 @@ function Squadra() {
|
||||
{rank[i]}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[15px] font-bold">
|
||||
<p className="truncate text-sm font-bold">
|
||||
{g.nome}
|
||||
{rank[i] === 1 ? (
|
||||
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
||||
) : null}
|
||||
</p>
|
||||
<p className="truncate text-[13px] text-muted-foreground">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -418,14 +414,14 @@ function Squadra() {
|
||||
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary-foreground/60">
|
||||
Progresso collettivo
|
||||
</p>
|
||||
<p className="font-display text-4xl leading-none">
|
||||
<Numero valore={mediaObiettivi} suffisso="%" />
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[13px] text-primary-foreground/70">
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
{completati}/{obiettivi.length} completati
|
||||
</p>
|
||||
</div>
|
||||
@@ -436,47 +432,38 @@ function Squadra() {
|
||||
const pct = progressoObiettivo(o);
|
||||
const fatto = pct >= 100;
|
||||
return (
|
||||
<Reveal key={o.id} indice={i}>
|
||||
<ObiettivoDrawer obiettivo={o}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
|
||||
<Reveal
|
||||
key={o.id}
|
||||
indice={i}
|
||||
className={cn(
|
||||
"premi rounded-3xl bg-card p-4 shadow-card ring-1",
|
||||
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg leading-none">{o.emoji}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold leading-tight">{o.titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">{o.descrizione}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"premi block w-full rounded-3xl bg-card p-4 text-left shadow-card ring-1",
|
||||
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
||||
"rounded-full px-2 py-0.5 text-xs font-bold uppercase tracking-wide",
|
||||
fatto
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg leading-none">{o.emoji}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-bold leading-tight">{o.titolo}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{o.descrizione}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
||||
fatto
|
||||
? "bg-success text-success-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{fatto ? "Completato" : `${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] font-semibold text-accent">
|
||||
{microcopyObiettivo(o)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">{o.impatto}</p>
|
||||
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
|
||||
Come funziona <ChevronRight className="h-3.5 w-3.5" />
|
||||
</p>
|
||||
</button>
|
||||
</ObiettivoDrawer>
|
||||
{fatto ? "Completato" : `${pct}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Barra percentuale={pct} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{o.valore}/{o.target} {o.unita} · {pct}%
|
||||
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">{microcopyObiettivo(o)}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{o.impatto}</p>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
@@ -496,10 +483,8 @@ function Squadra() {
|
||||
<div className="rounded-3xl bg-card p-3 shadow-card">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<p className="text-[15px] font-bold leading-tight">{b.nome}</p>
|
||||
<p className="ml-auto text-[13px] text-muted-foreground">
|
||||
{descrizioneSoglie(b)}
|
||||
</p>
|
||||
<p className="text-sm font-bold leading-tight">{b.nome}</p>
|
||||
<p className="ml-auto text-xs text-muted-foreground">{descrizioneSoglie(b)}</p>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
||||
{gradiOrdine.map((grado) => {
|
||||
@@ -515,11 +500,9 @@ function Squadra() {
|
||||
key={grado}
|
||||
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
||||
>
|
||||
<p className={cn("text-[13px] font-bold uppercase", meta.text)}>
|
||||
{meta.label}
|
||||
</p>
|
||||
<p className={cn("text-xs font-bold uppercase", meta.text)}>{meta.label}</p>
|
||||
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{quanti}/{rosa.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
-- M31 — Notifica di compleanno (DD-047, issue #11)
|
||||
--
|
||||
-- Ogni giorno, dalle 8:00 (fuso Europe/Rome), chi ha il compleanno riceve il solo titolo di auguri e tutti gli
|
||||
-- altri membri attivi della squadra (allenatori compresi) ricevono il solo avviso «Oggi è il compleanno
|
||||
-- di ...», senza corpo e senza età.
|
||||
-- Se in un giorno compiono gli anni più persone, ogni destinatario riceve un solo messaggio: chi non
|
||||
-- festeggia con tutti i nomi, ogni festeggiato solo i propri auguri.
|
||||
--
|
||||
-- Canali come le altre notifiche automatiche (DD-038): la riga in `notifiche_utente` genera la mail
|
||||
-- (il trigger di M26 lascia passare i nuovi tipi) e, con M31, anche la push dalla coda.
|
||||
--
|
||||
-- 29 febbraio: il confronto è tra giorno e mese, quindi chi è nato il 29 febbraio viene avvisato solo
|
||||
-- negli anni bisestili. Limite noto e accettato (DD-047): non c'è un ripiego al 28 febbraio.
|
||||
|
||||
-- --- Tipi di notifica --------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE public.notifiche_utente DROP CONSTRAINT notifiche_utente_tipo_check;
|
||||
ALTER TABLE public.notifiche_utente ADD CONSTRAINT notifiche_utente_tipo_check CHECK (
|
||||
tipo IN (
|
||||
'admin',
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato',
|
||||
'sollecita_presenze',
|
||||
'sollecita_presenze_24h',
|
||||
'sollecita_presenze_12h',
|
||||
'sollecita_presenze_6h',
|
||||
'sondaggio_cacche',
|
||||
'compleanno',
|
||||
'compleanno_auguri'
|
||||
)
|
||||
);
|
||||
|
||||
-- --- Registro ----------------------------------------------------------------------------------
|
||||
--
|
||||
-- Una riga per destinatario e giorno: ricorda che l'avviso di quel giorno è già stato generato,
|
||||
-- anche se il giocatore elimina la notifica (come `promemoria_eventi_generati`, DD-037). Non è legato
|
||||
-- a un evento: i compleanni non sono `eventi_app`. Sparisce con il giocatore (DD-029).
|
||||
|
||||
CREATE TABLE public.compleanni_notificati (
|
||||
giocatore_id text NOT NULL REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
giorno date NOT NULL,
|
||||
generato_il timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (giocatore_id, giorno)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.compleanni_notificati IS
|
||||
'Avvisi di compleanno già generati per destinatario e giorno: indipendente da notifiche_utente, così eliminare la notifica non la fa rigenerare (M31, DD-047).';
|
||||
|
||||
-- Solo la funzione SECURITY DEFINER e la service role la toccano: RLS attiva senza policy.
|
||||
ALTER TABLE public.compleanni_notificati ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.compleanni_notificati FROM PUBLIC, anon, authenticated;
|
||||
GRANT ALL ON public.compleanni_notificati TO service_role;
|
||||
|
||||
-- --- Job ---------------------------------------------------------------------------------------
|
||||
--
|
||||
-- Gira ogni ora e agisce solo dalle 8:00 in poi, ora locale: un giro mancato si recupera all'ora
|
||||
-- dopo e il registro impedisce di generare due volte lo stesso avviso. `pg_cron` ragiona in UTC e
|
||||
-- l'ora legale sposta l'ora locale di una ora: per questo l'ora si legge qui, in `Europe/Rome`.
|
||||
--
|
||||
-- Il registro è per destinatario e giorno: chi entra in squadra o inserisce la data di nascita dopo
|
||||
-- che gli avvisi del giorno sono partiti non genera un secondo giro (DD-047).
|
||||
--
|
||||
-- `p_adesso` esiste per i test, che simulano un'altra data; il job lo chiama senza argomenti.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.genera_avvisi_compleanno(p_adesso timestamptz DEFAULT now())
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
adesso timestamp := p_adesso AT TIME ZONE 'Europe/Rome';
|
||||
oggi date := (p_adesso AT TIME ZONE 'Europe/Rome')::date;
|
||||
BEGIN
|
||||
IF extract(hour FROM adesso) < 8 THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
WITH festeggiati AS (
|
||||
SELECT g.id, g.nome
|
||||
FROM public.giocatori_squadra g
|
||||
WHERE g.attivo = true
|
||||
AND g.nascita IS NOT NULL
|
||||
AND extract(month FROM g.nascita) = extract(month FROM oggi)
|
||||
AND extract(day FROM g.nascita) = extract(day FROM oggi)
|
||||
),
|
||||
elenco AS (
|
||||
SELECT array_agg(nome ORDER BY nome, id COLLATE "C") AS nomi
|
||||
FROM festeggiati
|
||||
),
|
||||
nuovi AS (
|
||||
INSERT INTO public.compleanni_notificati (giocatore_id, giorno)
|
||||
SELECT g.id, oggi
|
||||
FROM public.giocatori_squadra g
|
||||
WHERE g.attivo = true
|
||||
AND EXISTS (SELECT 1 FROM festeggiati)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING giocatore_id
|
||||
)
|
||||
INSERT INTO public.notifiche_utente (giocatore_id, tipo, titolo, corpo)
|
||||
SELECT n.giocatore_id, 'compleanno_auguri',
|
||||
'Buon compleanno, ' || f.nome || '! 🎂',
|
||||
''
|
||||
FROM nuovi n
|
||||
JOIN festeggiati f ON f.id = n.giocatore_id
|
||||
UNION ALL
|
||||
SELECT n.giocatore_id, 'compleanno',
|
||||
'Oggi è il compleanno di '
|
||||
|| CASE WHEN cardinality(e.nomi) = 1 THEN e.nomi[1]
|
||||
ELSE array_to_string(e.nomi[1:cardinality(e.nomi) - 1], ', ')
|
||||
|| ' e ' || e.nomi[cardinality(e.nomi)]
|
||||
END,
|
||||
''
|
||||
FROM nuovi n
|
||||
CROSS JOIN elenco e
|
||||
WHERE NOT EXISTS (SELECT 1 FROM festeggiati f WHERE f.id = n.giocatore_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.genera_avvisi_compleanno(timestamptz) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.genera_avvisi_compleanno(timestamptz) TO service_role;
|
||||
|
||||
SELECT cron.schedule(
|
||||
'avvisi-compleanno',
|
||||
'0 * * * *',
|
||||
$$ SELECT public.genera_avvisi_compleanno() $$
|
||||
);
|
||||
|
||||
-- --- Push: anche i compleanni ------------------------------------------------------------------
|
||||
--
|
||||
-- Nascono nel database come le altre notifiche automatiche: nessuna route le manda, quindi la push
|
||||
-- passa dalla coda (DD-038). Stesso trigger di M26 con i due tipi nuovi.
|
||||
|
||||
DROP TRIGGER notifiche_utente_accoda_push_promemoria ON public.notifiche_utente;
|
||||
CREATE TRIGGER notifiche_utente_accoda_push_promemoria
|
||||
AFTER INSERT ON public.notifiche_utente
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.tipo IN (
|
||||
'evento_promemoria_24h',
|
||||
'evento_promemoria_3h',
|
||||
'turno_palloni_12h',
|
||||
'turno_palloni_6h',
|
||||
'turno_palloni_3h',
|
||||
'turno_palloni_revocato',
|
||||
'sollecita_presenze_24h',
|
||||
'sollecita_presenze_12h',
|
||||
'sollecita_presenze_6h',
|
||||
'compleanno',
|
||||
'compleanno_auguri'
|
||||
))
|
||||
EXECUTE FUNCTION public.accoda_push_promemoria();
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -1,16 +0,0 @@
|
||||
-- M34 — Link Google Maps del luogo di un evento
|
||||
--
|
||||
-- Colonna facoltativa: NULL = nessun link. Il CHECK accetta solo URL https di Google Maps (stesso
|
||||
-- criterio di `linkMapsValido()` in src/lib/eventi.ts), perché il link è mostrato a tutta la squadra
|
||||
-- come pulsante. Chi lo scrive è già limitato ad admin e allenatori dalle policy di `eventi_app` (M11).
|
||||
|
||||
ALTER TABLE public.eventi_app
|
||||
ADD COLUMN link_maps text,
|
||||
ADD CONSTRAINT eventi_app_link_maps_google
|
||||
CHECK (
|
||||
link_maps IS NULL
|
||||
OR (
|
||||
length(link_maps) <= 500
|
||||
AND link_maps ~ '^https://(maps\.app\.goo\.gl/|goo\.gl/maps/|maps\.google\.[a-z.]+/|(www\.)?google\.[a-z.]+/maps)\S*$'
|
||||
)
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
-- M35 — Il voto MVP è definitivo (DD-051)
|
||||
--
|
||||
-- Prima un giocatore poteva cambiare o togliere il proprio voto (policy FOR ALL, M11/M13). Ora può
|
||||
-- solo inserirlo: UPDATE e DELETE restano agli admin (policy «Gli admin gestiscono tutti i voti
|
||||
-- MVP», M11), che possono correggere un voto sbagliato. Il vincolo UNIQUE (match_id, votante_id)
|
||||
-- fa il resto: un secondo voto sulla stessa partita viene rifiutato.
|
||||
|
||||
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
|
||||
|
||||
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
|
||||
);
|
||||
@@ -1,48 +0,0 @@
|
||||
-- M36 — Il voto MVP si controlla anche sulla presenza e sull'orario (DD-051)
|
||||
--
|
||||
-- Fino alla M35 il database controllava solo convocati e allenatori (`evento_permette_voto`): che
|
||||
-- votante e votato fossero presenti, e che fossero passate 2 ore dall'inizio, lo faceva solo l'app.
|
||||
-- Ora la policy di INSERT su `mvp_voti` usa `voto_mvp_consentito()`, che aggiunge:
|
||||
-- - l'evento è una partita;
|
||||
-- - votante e votato hanno risposto `presente` o `ritardo`;
|
||||
-- - sono passate 2 ore dall'inizio (stesso valore di ORE_ATTESA_MVP in src/lib/mvp-voti.ts).
|
||||
--
|
||||
-- È una funzione nuova e non una modifica di `evento_permette_voto`, perché questa è usata anche da
|
||||
-- pagelle e badge social, che non hanno queste regole. Gli admin restano fuori (policy M11): possono
|
||||
-- ancora correggere. L'ora si legge in Europe/Rome; un `ora` vuoto o non valido vale mezzanotte,
|
||||
-- come in `votoMvpAperto()`.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
|
||||
p_match_id text, p_votante_id text, p_votato_id text
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.eventi_app e
|
||||
WHERE e.id = p_match_id
|
||||
AND e.tipo = 'partita'
|
||||
AND ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||
THEN e.ora::time ELSE time '00:00' END)
|
||||
AT TIME ZONE 'Europe/Rome') + interval '2 hours' <= now()
|
||||
)
|
||||
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
|
||||
AND (
|
||||
SELECT count(DISTINCT r.giocatore_id)
|
||||
FROM public.risposte_presenze r
|
||||
WHERE r.evento_id = p_match_id
|
||||
AND r.giocatore_id IN (p_votante_id, p_votato_id)
|
||||
AND r.stato IN ('presente', 'ritardo')
|
||||
) = 2;
|
||||
$$;
|
||||
|
||||
DROP POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti;
|
||||
|
||||
CREATE POLICY "Ognuno registra il proprio voto MVP" ON public.mvp_voti
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
AND public.voto_mvp_consentito(match_id, votante_id, votato_id)
|
||||
);
|
||||
@@ -1,89 +0,0 @@
|
||||
-- M37 — Voto MVP segreto, nome del votato dal database, votazione che si chiude (DD-052)
|
||||
--
|
||||
-- 1. Segretezza. Finora `mvp_voti` era leggibile da tutti, quindi chiunque vedeva chi aveva votato chi.
|
||||
-- Ora la lettura diretta della tabella è del solo votante (il proprio voto) e degli admin (policy
|
||||
-- M11, che servono per correggere un voto: dall'app non vedono chi ha votato chi). Tutti gli altri
|
||||
-- leggono solo i conteggi, dalla vista `mvp_voti_anonimi`: una riga per voto, senza `votante_id`.
|
||||
-- 2. Nome. `votato_nome` lo scriveva il client, quindi poteva non corrispondere a `votato_id`. Un trigger
|
||||
-- lo riempie dalla rosa (`giocatori_squadra`) a partire da `votato_id`, ignorando quello inviato.
|
||||
-- 3. Chiusura. Il voto si apre 2 ore dopo l'inizio della partita (M36) e si chiude 24 ore dopo
|
||||
-- l'apertura. I valori sono gli stessi di ORE_ATTESA_MVP e DURATA_VOTO_MVP_ORE in
|
||||
-- src/lib/mvp-voti.ts. Gli admin restano fuori da questo vincolo.
|
||||
|
||||
-- --- 1. Segretezza ------------------------------------------------------------------------------
|
||||
DROP POLICY "Chiunque puo leggere i voti MVP" ON public.mvp_voti;
|
||||
|
||||
CREATE POLICY "Ognuno legge il proprio voto MVP" ON public.mvp_voti
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- La vista gira con i permessi del proprietario (security_invoker = false): è ciò che le permette di
|
||||
-- leggere tutte le righe pur senza esporre il votante. L'ordine è per candidato, non per arrivo, così
|
||||
-- la sequenza dei voti non rivela nulla.
|
||||
CREATE VIEW public.mvp_voti_anonimi WITH (security_invoker = false) AS
|
||||
SELECT match_id, votato_id, votato_nome
|
||||
FROM public.mvp_voti
|
||||
ORDER BY match_id, votato_id;
|
||||
|
||||
REVOKE ALL ON public.mvp_voti_anonimi FROM PUBLIC, anon, authenticated;
|
||||
GRANT SELECT ON public.mvp_voti_anonimi TO authenticated;
|
||||
GRANT ALL ON public.mvp_voti_anonimi TO service_role;
|
||||
|
||||
-- --- 2. Nome del votato dalla rosa ----------------------------------------------------------------
|
||||
-- Se `votato_id` non è in rosa (id di prova) resta il nome ricevuto: non c'è niente da sostituire.
|
||||
CREATE OR REPLACE FUNCTION public.mvp_voti_nome_dalla_rosa()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
nome_rosa text;
|
||||
BEGIN
|
||||
SELECT btrim(g.nome || ' ' || g.cognome) INTO nome_rosa
|
||||
FROM public.giocatori_squadra g
|
||||
WHERE g.id = NEW.votato_id;
|
||||
IF FOUND THEN
|
||||
NEW.votato_nome := nome_rosa;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER mvp_voti_nome_dalla_rosa
|
||||
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.mvp_voti
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
|
||||
|
||||
-- --- 3. Finestra di voto: apre a +2 ore, chiude 24 ore dopo l'apertura ------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.voto_mvp_consentito(
|
||||
p_match_id text, p_votante_id text, p_votato_id text
|
||||
) RETURNS boolean
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT public.evento_permette_voto(p_match_id, p_votante_id, p_votato_id, false)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM public.eventi_app e
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT ((e.data + CASE WHEN e.ora ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'
|
||||
THEN e.ora::time ELSE time '00:00' END)
|
||||
AT TIME ZONE 'Europe/Rome') AS inizio
|
||||
) t
|
||||
WHERE e.id = p_match_id
|
||||
AND e.tipo = 'partita'
|
||||
AND t.inizio + interval '2 hours' <= now()
|
||||
AND now() < t.inizio + interval '2 hours' + interval '24 hours'
|
||||
)
|
||||
-- votante e votato sono diversi (mvp_no_autovoto): due righe = entrambi presenti
|
||||
AND (
|
||||
SELECT count(DISTINCT r.giocatore_id)
|
||||
FROM public.risposte_presenze r
|
||||
WHERE r.evento_id = p_match_id
|
||||
AND r.giocatore_id IN (p_votante_id, p_votato_id)
|
||||
AND r.stato IN ('presente', 'ritardo')
|
||||
) = 2;
|
||||
$$;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- M38 — Anche i badge social prendono il nome del votato dalla rosa (DD-052)
|
||||
--
|
||||
-- Stesso difetto dell'MVP (M37): `badge_social_voti.votato_nome` lo scrive il client, quindi può non
|
||||
-- corrispondere a `votato_id`, e l'app lo mostra («Hai votato …», elenco dei più votati). Si riusa il
|
||||
-- trigger della M37: le due tabelle hanno le stesse colonne `votato_id` e `votato_nome`.
|
||||
-- Le pagelle non hanno un nome del votato, quindi non serve altro.
|
||||
|
||||
CREATE TRIGGER badge_social_voti_nome_dalla_rosa
|
||||
BEFORE INSERT OR UPDATE OF votato_id, votato_nome ON public.badge_social_voti
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.mvp_voti_nome_dalla_rosa();
|
||||
@@ -1,10 +0,0 @@
|
||||
-- M39 — Scout Live: valutazioni − / + per fondamentale.
|
||||
--
|
||||
-- Durante la partita lo scout può segnare, per ogni giocatore e per ogni set, un − o un +
|
||||
-- su battuta, ricezione, attacco e muro. Non sono punti: restano fuori dal punteggio e da
|
||||
-- `azioni`. Lo stato in corso le porta dentro `scout_live.stato` (jsonb, nessuna modifica);
|
||||
-- la partita archiviata le conserva in questa colonna, accanto alle azioni.
|
||||
-- Solo additiva: le partite già salvate ricevono l'elenco vuoto.
|
||||
|
||||
ALTER TABLE public.scout_partite
|
||||
ADD COLUMN valutazioni jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
+6
-6
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
||||
|
||||
## Struttura
|
||||
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), 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ì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
## Server di sviluppo con Node vecchio
|
||||
|
||||
|
||||
@@ -62,8 +62,10 @@ try {
|
||||
const csi = (await json(res)) as DatiCsi;
|
||||
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||
assert.ok(noi, "la nostra squadra arriva dal portale");
|
||||
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata.
|
||||
assert.ok(csi.partite.length > 0, "le gare della stagione arrivano dal portale");
|
||||
assert.ok(
|
||||
csi.partite.some((p) => p.setNostri !== null),
|
||||
"ci sono risultati giocati",
|
||||
);
|
||||
|
||||
const html = await pagina("/classifica");
|
||||
assert.match(titolo(html), /Classifica campionato/);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Data e ora (fuso Europe/Rome) di un istante a `ore` ore da adesso, nel formato di `eventi_app`
|
||||
* (`data` AAAA-MM-GG, `ora` HH:MM). Serve ai test del voto MVP, che dipende dall'orario di inizio.
|
||||
*/
|
||||
export function romaTra(ore: number): { data: string; ora: string } {
|
||||
const p: Record<string, string> = 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(new Date(Date.now() + ore * 3_600_000))
|
||||
.map((x) => [x.type, x.value]),
|
||||
);
|
||||
return { data: `${p["year"]}-${p["month"]}-${p["day"]}`, ora: `${p["hour"]}:${p["minute"]}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Una data con ora vuota vale mezzanotte. Ritorna il giorno la cui mezzanotte cade tra 2 e 26 ore
|
||||
* fa (il voto è aperto): oggi se sono passate almeno 2 ore dalla mezzanotte, altrimenti ieri.
|
||||
*/
|
||||
export function giornoSenzaOraAperto(): string {
|
||||
const adesso = romaTra(0);
|
||||
return Number(adesso.ora.slice(0, 2)) >= 2 ? adesso.data : romaTra(-24).data;
|
||||
}
|
||||
@@ -124,13 +124,9 @@ try {
|
||||
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
|
||||
});
|
||||
|
||||
// A inizio stagione il calendario c'è ma nessuna gara è ancora stata giocata: il test
|
||||
// del dettaglio non ha un bersaglio e si salta, come per il portale non raggiungibile.
|
||||
const nomeDettaglio = "GET /api/public/csi-partita/$id restituisce formazioni e precedenti";
|
||||
if (csiRaggiungibile && !partiteGiocate(csi?.partite ?? [])[0]) {
|
||||
salta(nomeDettaglio, "nessuna gara ancora giocata nella stagione");
|
||||
} else
|
||||
await provaCsi(nomeDettaglio, async () => {
|
||||
await provaCsi(
|
||||
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
|
||||
async () => {
|
||||
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
|
||||
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
|
||||
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||
@@ -151,7 +147,8 @@ try {
|
||||
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
||||
assert.equal(secondo.status, 200);
|
||||
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await provaCsi(
|
||||
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||
import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
|
||||
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
|
||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
@@ -52,11 +52,11 @@ if (!locale) {
|
||||
}
|
||||
|
||||
/** Rilegge esattamente come `useVotiMvp()`. */
|
||||
async function leggiVoti(): Promise<VotoMvpPubblico[]> {
|
||||
async function leggiVoti(): Promise<VotoMvp[]> {
|
||||
const res = await rest(
|
||||
`mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
|
||||
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
|
||||
);
|
||||
return (await res.json()) as VotoMvpPubblico[];
|
||||
return (await res.json()) as VotoMvp[];
|
||||
}
|
||||
|
||||
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||
@@ -68,8 +68,8 @@ if (!locale) {
|
||||
await prova(
|
||||
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
|
||||
async () => {
|
||||
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
|
||||
// voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
|
||||
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
|
||||
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
|
||||
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m1`,
|
||||
@@ -107,15 +107,17 @@ if (!locale) {
|
||||
votato_id: "vg1",
|
||||
votato_nome: "Uno",
|
||||
});
|
||||
await upsert({
|
||||
match_id: `${PREFISSO}-m3`,
|
||||
votante_id: "vb",
|
||||
votato_id: "vg2",
|
||||
votato_nome: "Due",
|
||||
});
|
||||
|
||||
const voti = await leggiVoti();
|
||||
const vinti = mvpVintiPerGiocatore(voti);
|
||||
assert.equal(
|
||||
vinti["vg1"],
|
||||
2,
|
||||
"vg1 vince m1 e m2, m3 ha un solo voto (sotto quorum, non conta)",
|
||||
);
|
||||
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai");
|
||||
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
|
||||
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
|
||||
|
||||
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
|
||||
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
|
||||
|
||||
@@ -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`.
|
||||
* Copre gli obiettivi con scadenza/mese dinamici ("presenze del mese", "evento di squadra al
|
||||
* mese", "tutti rispondono alle convocazioni") e quelli la cui logica dipende da dati scritti
|
||||
* su altre tabelle ("presenze collettive" via `leggiEventi()`/`leggiPresenze()`, "media pagelle
|
||||
* su altre tabelle ("250 presenze complessive" via `contaPresenzeGiocatore()`, "media pagelle
|
||||
* da 7.5" e "200 pagelle compilate" via `pagelle_voti`).
|
||||
*
|
||||
* I test unitari (`test/unit/obiettivi.test.ts`) verificano `obiettiviSquadra()` come funzione
|
||||
@@ -23,7 +23,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { obiettiviSquadra } from "@/lib/obiettivi";
|
||||
import { serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
||||
import { contaPresenzeGiocatore, serieConsecutiva, type MappaPresenze } from "@/lib/presenze";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
@@ -282,51 +282,33 @@ if (!locale) {
|
||||
"o6 legge dal database l'evento sociale del mese, azzerandosi come o1",
|
||||
async () => {
|
||||
const pizzataId = `${PREFISSO}-pizzata`;
|
||||
const pizzataFuturaId = `${PREFISSO}-pizzata-futura`;
|
||||
// OGGI è il 15 marzo 12:00 a Roma: la pizzata del 10 è passata, quella del 20 no.
|
||||
for (const [id, giorno] of [
|
||||
[pizzataId, "10"],
|
||||
[pizzataFuturaId, "20"],
|
||||
] as const) {
|
||||
const inserita = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
tipo: "evento",
|
||||
titolo: "Test obiettivi o6",
|
||||
data: `${MESE_TEST}-${giorno}`,
|
||||
ora: "20:30",
|
||||
}),
|
||||
});
|
||||
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
||||
}
|
||||
const inserita = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: pizzataId,
|
||||
tipo: "evento",
|
||||
titolo: "Test obiettivi o6",
|
||||
data: `${MESE_TEST}-20`,
|
||||
}),
|
||||
});
|
||||
if (!inserita.ok) throw new Error(`inserimento evento fallito: ${await inserita.text()}`);
|
||||
|
||||
const tutti = await leggiEventi();
|
||||
const o6Di = (ids: string[], oggi: Date) =>
|
||||
obiettiviSquadra(
|
||||
giocatori,
|
||||
{ eventi: tutti.filter((e) => ids.includes(e.id)), presenze: {}, pagelle: [] },
|
||||
oggi,
|
||||
).find((o) => o.id === "o6")!;
|
||||
const eventiReali = await leggiEventi();
|
||||
const o6InMese = obiettiviSquadra(
|
||||
giocatori,
|
||||
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||
OGGI,
|
||||
).find((o) => o.id === "o6")!;
|
||||
assert.equal(o6InMese.valore, 1, "l'evento sociale di marzo conta letto dal database");
|
||||
|
||||
const OGGI_MESE_DOPO = new Date("2099-04-15T10:00:00Z");
|
||||
const o6MeseDopo = obiettiviSquadra(
|
||||
giocatori,
|
||||
{ eventi: eventiReali, presenze: {}, pagelle: [] },
|
||||
OGGI_MESE_DOPO,
|
||||
).find((o) => o.id === "o6")!;
|
||||
assert.equal(
|
||||
o6Di([pizzataId], OGGI).valore,
|
||||
1,
|
||||
"l'evento sociale già passato conta, letto dal database",
|
||||
);
|
||||
assert.equal(
|
||||
o6Di([pizzataFuturaId], OGGI).valore,
|
||||
0,
|
||||
"l'evento sociale futuro non conta finché non arriva la sua ora",
|
||||
);
|
||||
// Il 20 marzo 2099 alle 20:30 Roma (CET, UTC+1) = 19:30Z: da lì conta.
|
||||
assert.equal(
|
||||
o6Di([pizzataFuturaId], new Date("2099-03-20T19:30:00Z")).valore,
|
||||
1,
|
||||
"arrivata l'ora dell'evento conta",
|
||||
);
|
||||
assert.equal(
|
||||
o6Di([pizzataId], new Date("2099-04-15T10:00:00Z")).valore,
|
||||
o6MeseDopo.valore,
|
||||
0,
|
||||
"lo stesso evento non conta più il mese successivo (si azzera)",
|
||||
);
|
||||
@@ -404,129 +386,68 @@ if (!locale) {
|
||||
},
|
||||
);
|
||||
|
||||
await prova(
|
||||
"o7 percentuale di presenze collettive da eventi/risposte reali del database",
|
||||
async () => {
|
||||
// o7 è la % di presenze su allenamenti e partite definiti (passati e futuri), con i
|
||||
// convocati come denominatore. Si esercita l'intera catena DB -> leggiEventi/leggiPresenze -> o7.
|
||||
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||
const partitaId = `${PREFISSO}-o7-partita`;
|
||||
const [g1, g2, g3] = giocatori;
|
||||
await prova("o7 somma presenze calcolate da eventi/risposte reali del database", async () => {
|
||||
// o7 non calcola nulla da `ctx`: somma `g.presenze`, un campo già calcolato a monte da
|
||||
// `contaPresenzeGiocatore()` (che in produzione alimenta `useRosa()`). Qui si esercita
|
||||
// la stessa funzione pura sui dati appena scritti, per verificare l'intera catena
|
||||
// DB -> contaPresenzeGiocatore -> o7, non solo la somma finale.
|
||||
const allenamentoId = `${PREFISSO}-o7-allenamento`;
|
||||
const partitaId = `${PREFISSO}-o7-partita`;
|
||||
const OGGI_STR = "2099-01-20";
|
||||
|
||||
for (const [id, tipo, data] of [
|
||||
[allenamentoId, "allenamento", "2099-01-05"],
|
||||
[partitaId, "partita", "2099-01-08"],
|
||||
] as const) {
|
||||
const inserito = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
tipo,
|
||||
titolo: `Test obiettivi o7 (${tipo})`,
|
||||
data,
|
||||
convocati: [g1!.id, g2!.id, g3!.id],
|
||||
}),
|
||||
});
|
||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||
}
|
||||
|
||||
// g1 presente a entrambi, g2 presente e in ritardo (il ritardo conta), g3 assente a
|
||||
// entrambi: 4 presenze su 6 posti (3 convocati x 2 eventi) = 67%.
|
||||
const righe = [
|
||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
||||
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
||||
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
||||
];
|
||||
const inserite = await rest("risposte_presenze", {
|
||||
for (const [id, tipo, data] of [
|
||||
[allenamentoId, "allenamento", "2099-01-05"],
|
||||
[partitaId, "partita", "2099-01-08"],
|
||||
] as const) {
|
||||
const inserito = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(righe),
|
||||
body: JSON.stringify({ id, tipo, titolo: `Test obiettivi o7 (${tipo})`, data }),
|
||||
});
|
||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||
}
|
||||
|
||||
const eventiReali = (await leggiEventi()).filter(
|
||||
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||
);
|
||||
const presenzeReali = {
|
||||
...(await leggiPresenze(allenamentoId)),
|
||||
...(await leggiPresenze(partitaId)),
|
||||
};
|
||||
// g1: presente ai due eventi (2 presenze). g2: presente e in ritardo (2 presenze,
|
||||
// il ritardo conta). g3: assente a entrambi (0 presenze).
|
||||
const [g1, g2, g3] = giocatori;
|
||||
const righe = [
|
||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||
{ evento_id: partitaId, giocatore_id: g1!.id, stato: "presente" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "presente" },
|
||||
{ evento_id: partitaId, giocatore_id: g2!.id, stato: "ritardo" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "assente" },
|
||||
{ evento_id: partitaId, giocatore_id: g3!.id, stato: "assente" },
|
||||
];
|
||||
const inserite = await rest("risposte_presenze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(righe),
|
||||
});
|
||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||
|
||||
const o7 = obiettiviSquadra([g1!, g2!, g3!], {
|
||||
eventi: eventiReali,
|
||||
presenze: presenzeReali,
|
||||
pagelle: [],
|
||||
}).find((o) => o.id === "o7")!;
|
||||
const eventiReali = (await leggiEventi()).filter(
|
||||
(e) => e.id === allenamentoId || e.id === partitaId,
|
||||
);
|
||||
const presenzeReali = {
|
||||
...(await leggiPresenze(allenamentoId)),
|
||||
...(await leggiPresenze(partitaId)),
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
o7.valore,
|
||||
67,
|
||||
"4 presenze su 6 posti (il ritardo conta), calcolate dal database",
|
||||
);
|
||||
},
|
||||
);
|
||||
const rosaConPresenzeReali = [g1!, g2!, g3!].map((g) => ({
|
||||
...g,
|
||||
presenze: contaPresenzeGiocatore(g.id, eventiReali, presenzeReali, OGGI_STR),
|
||||
}));
|
||||
|
||||
await prova(
|
||||
"o1 usa i convocati letti dal database: il non convocato non pesa, l'assente sì, la cena no",
|
||||
async () => {
|
||||
// Stesso mese di test di o1 (marzo 2099). Allenamento con 3 convocati su 14 in rosa:
|
||||
// g1 presente, g2 assente (presenza persa), g3 in ritardo. g4 non è convocato ma risponde
|
||||
// «assente»: non deve pesare. La cena del mese ha risposte «assente» e non deve contare.
|
||||
const allenamentoId = `${PREFISSO}-o1-convocati-allenamento`;
|
||||
const cenaId = `${PREFISSO}-o1-convocati-cena`;
|
||||
const [g1, g2, g3, g4] = giocatori;
|
||||
for (const [id, tipo, data, convocati] of [
|
||||
[allenamentoId, "allenamento", "2099-03-12", [g1!.id, g2!.id, g3!.id]],
|
||||
[cenaId, "evento", "2099-03-14", [g1!.id, g2!.id, g3!.id]],
|
||||
] as const) {
|
||||
const inserito = await rest("eventi_app", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
tipo,
|
||||
titolo: `Test o1 convocati (${tipo})`,
|
||||
data,
|
||||
convocati,
|
||||
}),
|
||||
});
|
||||
if (!inserito.ok) throw new Error(`inserimento evento fallito: ${await inserito.text()}`);
|
||||
}
|
||||
const righe = [
|
||||
{ evento_id: allenamentoId, giocatore_id: g1!.id, stato: "presente" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g2!.id, stato: "assente" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g3!.id, stato: "ritardo" },
|
||||
{ evento_id: allenamentoId, giocatore_id: g4!.id, stato: "assente" },
|
||||
{ evento_id: cenaId, giocatore_id: g1!.id, stato: "assente" },
|
||||
{ evento_id: cenaId, giocatore_id: g2!.id, stato: "assente" },
|
||||
];
|
||||
const inserite = await rest("risposte_presenze", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(righe),
|
||||
});
|
||||
if (!inserite.ok) throw new Error(`inserimento presenze fallito: ${await inserite.text()}`);
|
||||
const o7 = obiettiviSquadra(rosaConPresenzeReali, {
|
||||
eventi: [],
|
||||
presenze: {},
|
||||
pagelle: [],
|
||||
}).find((o) => o.id === "o7")!;
|
||||
|
||||
const ids = [allenamentoId, cenaId];
|
||||
const eventiReali = (await leggiEventi()).filter((e) => ids.includes(e.id));
|
||||
const presenzeReali = {
|
||||
...(await leggiPresenze(allenamentoId)),
|
||||
...(await leggiPresenze(cenaId)),
|
||||
};
|
||||
|
||||
const o1 = obiettiviSquadra(
|
||||
giocatori,
|
||||
{ eventi: eventiReali, presenze: presenzeReali, pagelle: [] },
|
||||
OGGI,
|
||||
).find((o) => o.id === "o1")!;
|
||||
|
||||
assert.equal(
|
||||
o1.valore,
|
||||
67,
|
||||
"2 presenti (presente e ritardo) su 3 convocati; g4 non convocato e la cena non contano",
|
||||
);
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
o7.valore,
|
||||
4,
|
||||
"g1 (2) + g2 (2, il ritardo conta) + g3 (0) = 4, calcolate dal database",
|
||||
);
|
||||
});
|
||||
|
||||
await prova("o12/o13 media e conteggio pagelle vere lette da pagelle_voti", async () => {
|
||||
const matchId = `${PREFISSO}-o12-m1`;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
|
||||
* lo slot g3 reclamato dal giocatore di prova e le righe con il prefisso `test-allenatore`.
|
||||
*/
|
||||
import { romaTra } from "../helpers/roma";
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
@@ -260,34 +259,8 @@ if (!locale) {
|
||||
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
|
||||
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
// M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
|
||||
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
|
||||
body: JSON.stringify({ convocati: [] }),
|
||||
});
|
||||
// M36: perché il rifiuto dipenda solo dal ruolo, tutti sono presenti (lo slot dell'allenatore
|
||||
// lo segniamo presente a mano: da sé non potrebbe rispondere) e la partita è passata.
|
||||
for (const g of [GIOCATORE, SLOT, "g5"]) {
|
||||
await rest("risposte_presenze", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates" },
|
||||
body: JSON.stringify({ evento_id: EVENTO, giocatore_id: g, stato: "presente" }),
|
||||
});
|
||||
}
|
||||
// Controllo positivo: due giocatori presenti, il voto passa.
|
||||
const valido = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO,
|
||||
votante_id: GIOCATORE,
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
}),
|
||||
});
|
||||
assert.equal(
|
||||
await righeToccate(valido),
|
||||
1,
|
||||
`un giocatore vota un giocatore (${valido.status})`,
|
||||
);
|
||||
const vota = await rest("mvp_voti", tokenAllenatore, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { statoLocale } from "../helpers/locale";
|
||||
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const locale = statoLocale();
|
||||
@@ -311,47 +310,12 @@ if (!locale) {
|
||||
}),
|
||||
});
|
||||
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
|
||||
});
|
||||
|
||||
// --- Voto MVP: definitivo (M35), solo tra presenti e dopo 2 ore dall'inizio (M36) ----------
|
||||
let contatoreMvp = 0;
|
||||
/** Crea una partita (o altro tipo) con le presenze indicate; ritorna l'id. */
|
||||
async function eventoMvp(
|
||||
quando: { data: string; ora: string },
|
||||
presenze: Record<string, string>,
|
||||
tipo = "partita",
|
||||
): Promise<string> {
|
||||
const id = `${PREFISSO}-mvp-${++contatoreMvp}`;
|
||||
const creato = await rest("eventi_app", tokenAdmin, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ id, tipo, titolo: "Voto MVP", luogo: "Palestra", ...quando }),
|
||||
});
|
||||
assert.equal(await righeToccate(creato), 1, "l'evento di prova si crea");
|
||||
for (const [giocatore_id, stato] of Object.entries(presenze)) {
|
||||
const r = await rest("risposte_presenze", SERVIZIO, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ evento_id: id, giocatore_id, stato }),
|
||||
});
|
||||
assert.ok(r.ok, `presenza di prova ${giocatore_id}: ${r.status}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
const votaMvp = (token: string, match_id: string, votante_id: string, votato_id: string) =>
|
||||
rest("mvp_voti", token, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ match_id, votante_id, votato_id, votato_nome: votato_id }),
|
||||
});
|
||||
const passata = romaTra(-3);
|
||||
|
||||
await prova("il voto MVP è uno solo, definitivo e firmato (M35)", async () => {
|
||||
const EVENTO_MVP = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
const mioMvp = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_MVP,
|
||||
match_id: EVENTO,
|
||||
votante_id: "g1",
|
||||
votato_id: "g5",
|
||||
votato_nome: "Cinque",
|
||||
@@ -359,55 +323,11 @@ if (!locale) {
|
||||
});
|
||||
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
|
||||
|
||||
// Il voto MVP è definitivo (M35): né un secondo voto, né una modifica, né la cancellazione.
|
||||
const secondo = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_MVP,
|
||||
votante_id: "g1",
|
||||
votato_id: "g6",
|
||||
votato_nome: "Sei",
|
||||
}),
|
||||
});
|
||||
assert.ok(
|
||||
!secondo.ok,
|
||||
`un secondo voto MVP sulla stessa partita è rifiutato (${secondo.status})`,
|
||||
);
|
||||
const modifica = await rest(
|
||||
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
|
||||
tokenGiocatore,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ votato_id: "g6", votato_nome: "Sei" }),
|
||||
},
|
||||
);
|
||||
assert.equal(await righeToccate(modifica), 0, "il proprio voto MVP non si modifica");
|
||||
const cancella = await rest(
|
||||
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1`,
|
||||
tokenGiocatore,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Prefer: "return=representation" },
|
||||
},
|
||||
);
|
||||
assert.equal(await righeToccate(cancella), 0, "il proprio voto MVP non si cancella");
|
||||
const dopo = await rest(
|
||||
`mvp_voti?match_id=eq.${EVENTO_MVP}&votante_id=eq.g1&select=votato_id`,
|
||||
SERVIZIO,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await dopo.json(),
|
||||
[{ votato_id: "g5" }],
|
||||
"il voto è rimasto quello iniziale",
|
||||
);
|
||||
|
||||
const mvp = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_MVP,
|
||||
match_id: EVENTO,
|
||||
votante_id: "g5",
|
||||
votato_id: "g1",
|
||||
votato_nome: "Uno",
|
||||
@@ -416,265 +336,6 @@ if (!locale) {
|
||||
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
|
||||
});
|
||||
|
||||
await prova("il voto MVP vale solo tra presenti (M36)", async () => {
|
||||
const q = passata;
|
||||
// Controllo positivo: presente e in ritardo possono votare e farsi votare.
|
||||
const ok = await eventoMvp(q, { g1: "presente", g5: "ritardo" });
|
||||
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, ok, "g1", "g5")), 1);
|
||||
const okRitardo = await eventoMvp(q, { g1: "ritardo", g5: "presente" });
|
||||
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, okRitardo, "g1", "g5")), 1);
|
||||
|
||||
for (const [stato, nome] of [
|
||||
["forse", "forse"],
|
||||
["assente", "assente"],
|
||||
["infortunato", "infortunato"],
|
||||
[null, "senza risposta"],
|
||||
] as const) {
|
||||
const votatoNo = await eventoMvp(q, {
|
||||
g1: "presente",
|
||||
...(stato ? { g5: stato } : {}),
|
||||
});
|
||||
const r = await votaMvp(tokenGiocatore, votatoNo, "g1", "g5");
|
||||
assert.ok(!r.ok, `un votato ${nome} non si può votare (${r.status})`);
|
||||
|
||||
const votanteNo = await eventoMvp(q, {
|
||||
g5: "presente",
|
||||
...(stato ? { g1: stato } : {}),
|
||||
});
|
||||
const r2 = await votaMvp(tokenGiocatore, votanteNo, "g1", "g5");
|
||||
assert.ok(!r2.ok, `un votante ${nome} non può votare (${r2.status})`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova(
|
||||
"il voto MVP apre 2 ore dopo l'inizio e chiude 24 ore dopo (M36, M37)",
|
||||
async () => {
|
||||
const presenti = { g1: "presente", g5: "presente" };
|
||||
const tre = await eventoMvp(romaTra(-3), presenti);
|
||||
assert.equal(
|
||||
await righeToccate(await votaMvp(tokenGiocatore, tre, "g1", "g5")),
|
||||
1,
|
||||
"3 ore dopo",
|
||||
);
|
||||
|
||||
const una = await eventoMvp(romaTra(-1), presenti);
|
||||
const rUna = await votaMvp(tokenGiocatore, una, "g1", "g5");
|
||||
assert.ok(!rUna.ok, `1 ora dopo l'inizio: ancora chiuso (${rUna.status})`);
|
||||
|
||||
const venticinque = await eventoMvp(romaTra(-25), presenti);
|
||||
assert.equal(
|
||||
await righeToccate(await votaMvp(tokenGiocatore, venticinque, "g1", "g5")),
|
||||
1,
|
||||
"25 ore dopo l'inizio: ancora aperto (chiude a 26)",
|
||||
);
|
||||
const ventisette = await eventoMvp(romaTra(-27), presenti);
|
||||
const rChiuso = await votaMvp(tokenGiocatore, ventisette, "g1", "g5");
|
||||
assert.ok(!rChiuso.ok, `27 ore dopo l'inizio: chiuso (${rChiuso.status})`);
|
||||
|
||||
const futura = await eventoMvp(romaTra(48), presenti);
|
||||
const rFutura = await votaMvp(tokenGiocatore, futura, "g1", "g5");
|
||||
assert.ok(!rFutura.ok, `partita non ancora giocata (${rFutura.status})`);
|
||||
|
||||
// Un'ora vuota vale mezzanotte, come nell'app.
|
||||
const senzaOra = await eventoMvp({ data: giornoSenzaOraAperto(), ora: "" }, presenti);
|
||||
assert.equal(
|
||||
await righeToccate(await votaMvp(tokenGiocatore, senzaOra, "g1", "g5")),
|
||||
1,
|
||||
"senza ora vale mezzanotte",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await prova("il voto MVP esiste solo per le partite (M36)", async () => {
|
||||
const presenti = { g1: "presente", g5: "presente" };
|
||||
for (const tipo of ["allenamento", "evento"]) {
|
||||
const e = await eventoMvp(passata, presenti, tipo);
|
||||
const r = await votaMvp(tokenGiocatore, e, "g1", "g5");
|
||||
assert.ok(!r.ok, `nessun MVP per ${tipo} (${r.status})`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("l'admin può registrare un voto MVP anche fuori da queste regole", async () => {
|
||||
const futura = await eventoMvp(romaTra(48), { g1: "assente" });
|
||||
const r = await votaMvp(tokenAdmin, futura, "g1", "g5");
|
||||
assert.equal(await righeToccate(r), 1, "l'admin corregge/inserisce senza vincoli di M36");
|
||||
const chiusa = await eventoMvp(romaTra(-48), { g1: "assente" });
|
||||
const rc = await votaMvp(tokenAdmin, chiusa, "g1", "g5");
|
||||
assert.equal(await righeToccate(rc), 1, "l'admin può votare anche a votazione chiusa");
|
||||
});
|
||||
|
||||
await prova("il voto MVP è segreto: nessuno legge chi ha votato chi (M37)", async () => {
|
||||
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
assert.equal(await righeToccate(await votaMvp(tokenGiocatore, e, "g1", "g5")), 1);
|
||||
// Un secondo voto di un altro votante (inserito dall'admin) per avere due righe.
|
||||
assert.equal(await righeToccate(await votaMvp(tokenAdmin, e, "g5", "g1")), 1);
|
||||
|
||||
const tabella = await rest(
|
||||
`mvp_voti?match_id=eq.${e}&select=votante_id,votato_id`,
|
||||
tokenGiocatore,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await tabella.json(),
|
||||
[{ votante_id: "g1", votato_id: "g5" }],
|
||||
"dalla tabella un giocatore legge solo il proprio voto",
|
||||
);
|
||||
|
||||
const vista = await rest(`mvp_voti_anonimi?match_id=eq.${e}&select=*`, tokenGiocatore);
|
||||
assert.ok(vista.ok, `la vista dei conteggi si legge (${vista.status})`);
|
||||
const righe = (await vista.json()) as Record<string, string>[];
|
||||
assert.deepEqual(
|
||||
righe.map((r) => r["votato_id"]).sort(),
|
||||
["g1", "g5"],
|
||||
"ci sono tutti i voti",
|
||||
);
|
||||
assert.ok(
|
||||
righe.every((r) => !("votante_id" in r)),
|
||||
"ma la vista non dice chi li ha dati",
|
||||
);
|
||||
|
||||
const anonimo = await rest(`mvp_voti_anonimi?match_id=eq.${e}`, ANON);
|
||||
assert.ok(!anonimo.ok, `senza account non si legge nemmeno la vista (${anonimo.status})`);
|
||||
const anonimoTabella = await rest(`mvp_voti?match_id=eq.${e}`, ANON);
|
||||
const corpo = anonimoTabella.ok ? ((await anonimoTabella.json()) as unknown[]) : [];
|
||||
assert.equal(corpo.length, 0, "né la tabella");
|
||||
|
||||
const admin = await rest(`mvp_voti?match_id=eq.${e}&select=votante_id`, tokenAdmin);
|
||||
assert.equal(
|
||||
((await admin.json()) as unknown[]).length,
|
||||
2,
|
||||
"l'admin vede i voti dalla tabella: gli servono per correggerli",
|
||||
);
|
||||
});
|
||||
|
||||
/** Nome in rosa di uno slot, come lo compone il trigger (nome + cognome, senza spazi in testa). */
|
||||
async function nomeInRosa(id: string): Promise<string> {
|
||||
const r = await rest(`giocatori_squadra?id=eq.${id}&select=nome,cognome`, SERVIZIO);
|
||||
const [g] = (await r.json()) as { nome: string; cognome: string }[];
|
||||
return `${g!.nome} ${g!.cognome}`.trim();
|
||||
}
|
||||
const votoNome = (token: string, match_id: string, votato_id: string, resto: object) =>
|
||||
rest("mvp_voti", token, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ match_id, votante_id: "g1", votato_id, ...resto }),
|
||||
});
|
||||
const nomeRiga = async (res: Response) =>
|
||||
((await res.json()) as { votato_nome: string }[])[0]?.votato_nome;
|
||||
|
||||
await prova("MVP: il nome del votato lo mette il database, non il client (M37)", async () => {
|
||||
const g5 = await nomeInRosa("g5");
|
||||
|
||||
// Nome inventato: ignorato.
|
||||
const falso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
assert.equal(
|
||||
await nomeRiga(
|
||||
await votoNome(tokenGiocatore, falso, "g5", { votato_nome: "Nome Inventato" }),
|
||||
),
|
||||
g5,
|
||||
);
|
||||
const visto = await rest(
|
||||
`mvp_voti_anonimi?match_id=eq.${falso}&select=votato_nome`,
|
||||
tokenGiocatore,
|
||||
);
|
||||
assert.deepEqual(await visto.json(), [{ votato_nome: g5 }], "e così lo vede la squadra");
|
||||
|
||||
// Nome omesso (la colonna è NOT NULL): lo riempie il trigger, il voto passa.
|
||||
const omesso = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
assert.equal(
|
||||
await nomeRiga(await votoNome(tokenGiocatore, omesso, "g5", {})),
|
||||
g5,
|
||||
"nome omesso",
|
||||
);
|
||||
|
||||
// Nome vuoto, con spazi, con un altro giocatore: sempre quello della rosa.
|
||||
for (const [i, strano] of ["", " ", await nomeInRosa("g1"), "<b>x</b>"].entries()) {
|
||||
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
assert.equal(
|
||||
await nomeRiga(await votoNome(tokenGiocatore, e, "g5", { votato_nome: strano })),
|
||||
g5,
|
||||
`caso ${i}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Nome già corretto: resta lo stesso.
|
||||
const giusto = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
assert.equal(
|
||||
await nomeRiga(await votoNome(tokenGiocatore, giusto, "g5", { votato_nome: g5 })),
|
||||
g5,
|
||||
);
|
||||
});
|
||||
|
||||
await prova(
|
||||
"MVP: l'admin che corregge un voto non può far divergere il nome (M37)",
|
||||
async () => {
|
||||
const [g5, g6] = [await nomeInRosa("g5"), await nomeInRosa("g6")];
|
||||
const e = await eventoMvp(passata, { g1: "presente", g5: "presente", g6: "presente" });
|
||||
await votoNome(tokenGiocatore, e, "g5", {});
|
||||
const filtro = `mvp_voti?match_id=eq.${e}&votante_id=eq.g1`;
|
||||
|
||||
// Cambia solo il nome: torna quello della rosa.
|
||||
const soloNome = await rest(filtro, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ votato_nome: "Falso" }),
|
||||
});
|
||||
assert.equal(await nomeRiga(soloNome), g5, "nome riscritto da solo");
|
||||
|
||||
// Cambia il votato: il nome lo segue.
|
||||
const altro = await rest(filtro, tokenAdmin, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ votato_id: "g6", votato_nome: "Falso" }),
|
||||
});
|
||||
assert.equal(await nomeRiga(altro), g6, "nome che segue il nuovo votato");
|
||||
},
|
||||
);
|
||||
|
||||
await prova("MVP: un id fuori rosa conserva il nome ricevuto (M37)", async () => {
|
||||
// Solo la service role (come i test sui badge) può scrivere id che non sono in rosa.
|
||||
const e = `${PREFISSO}-mvp-fuori-rosa`;
|
||||
const r = await rest("mvp_voti", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: e,
|
||||
votante_id: "xx1",
|
||||
votato_id: "xx2",
|
||||
votato_nome: "Ospite",
|
||||
}),
|
||||
});
|
||||
assert.equal(await nomeRiga(r), "Ospite", "niente da sostituire: resta il nome ricevuto");
|
||||
});
|
||||
|
||||
await prova("badge social: il nome del votato lo mette il database (M38)", async () => {
|
||||
const g5 = await nomeInRosa("g5");
|
||||
const e = await eventoMvp(passata, { g1: "presente", g5: "presente" });
|
||||
const base = { match_id: e, categoria: "sorriso", votante_id: "g1", votato_id: "g5" };
|
||||
const post = (resto: object) =>
|
||||
rest("badge_social_voti?on_conflict=match_id,categoria,votante_id", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({ ...base, ...resto }),
|
||||
});
|
||||
assert.equal(
|
||||
await nomeRiga(await post({ votato_nome: "Nome Inventato" })),
|
||||
g5,
|
||||
"nome inventato",
|
||||
);
|
||||
// Stesso voto rinviato con un altro nome (upsert): il nome resta quello della rosa.
|
||||
assert.equal(
|
||||
await nomeRiga(await post({ votato_nome: "Un Altro" })),
|
||||
g5,
|
||||
"upsert con altro nome",
|
||||
);
|
||||
const omesso = await rest("badge_social_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ ...base, categoria: "meme" }),
|
||||
});
|
||||
assert.equal(await nomeRiga(omesso), g5, "nome omesso");
|
||||
});
|
||||
|
||||
await prova("le cacche le dichiara il diretto interessato", async () => {
|
||||
const mie = await rest("cacche_partita", tokenGiocatore, {
|
||||
method: "POST",
|
||||
@@ -794,8 +455,8 @@ if (!locale) {
|
||||
id: EVENTO_CON_G1,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con g1 convocato, g2 no",
|
||||
data: romaTra(-3).data,
|
||||
ora: romaTra(-3).ora,
|
||||
data: "2026-01-02",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
convocati: ["g1", "g5"],
|
||||
}),
|
||||
@@ -821,17 +482,9 @@ if (!locale) {
|
||||
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
||||
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
||||
// (es. un evento inesistente) e sembrare comunque corretto.
|
||||
// M36: il voto MVP richiede che votante e votato siano presenti.
|
||||
for (const g of ["g1", "g5"]) {
|
||||
await rest("risposte_presenze", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates" },
|
||||
body: JSON.stringify({ evento_id: EVENTO_CON_G1, giocatore_id: g, stato: "presente" }),
|
||||
});
|
||||
}
|
||||
const votoValido = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CON_G1,
|
||||
votante_id: "g1",
|
||||
@@ -850,8 +503,8 @@ if (!locale) {
|
||||
id: EVENTO_CHIUSO,
|
||||
tipo: "partita",
|
||||
titolo: "Partita con pagelle chiuse",
|
||||
data: romaTra(-3).data,
|
||||
ora: romaTra(-3).ora,
|
||||
data: "2026-01-03",
|
||||
ora: "20:00",
|
||||
luogo: "Palestra",
|
||||
pagelle_chiuse: true,
|
||||
}),
|
||||
@@ -876,17 +529,9 @@ if (!locale) {
|
||||
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
|
||||
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
|
||||
// votabili sullo stesso evento.
|
||||
// M36: il voto MVP richiede che votante e votato siano presenti.
|
||||
for (const g of ["g1", "g5"]) {
|
||||
await rest("risposte_presenze", SERVIZIO, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "resolution=merge-duplicates" },
|
||||
body: JSON.stringify({ evento_id: EVENTO_CHIUSO, giocatore_id: g, stato: "presente" }),
|
||||
});
|
||||
}
|
||||
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
||||
body: JSON.stringify({
|
||||
match_id: EVENTO_CHIUSO,
|
||||
votante_id: "g1",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user