Compare commits
18
Commits
v1.3.1
...
9d047a6ecd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d047a6ecd | ||
|
|
5fed105850 | ||
|
|
127d5cb5b8 | ||
|
|
e5fac24916 | ||
|
|
b17605ae76 | ||
|
|
92e7195357 | ||
|
|
215090d297 | ||
|
|
a69bcb3f0d | ||
|
|
19ddf715e6 | ||
|
|
2f583ca97f | ||
|
|
7996dd050e | ||
|
|
5ee148b88b | ||
|
|
8def97731c | ||
|
|
5ca081a6af | ||
|
|
3bf07049e9 | ||
|
|
986098d933 | ||
|
|
274fed93c5 | ||
|
|
f0e6a3a6cc |
No files matched your search
+24
-14
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 06/10/2026
|
Ultimo aggiornamento: 26/10/2026
|
||||||
|
|
||||||
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
Questo file dice **a che punto siamo ora**. Le funzionalità stanno in
|
||||||
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
[docs/ROADMAP.md](docs/ROADMAP.md), i rilasci in [docs/CHANGELOG.md](docs/CHANGELOG.md), lo
|
||||||
@@ -9,33 +9,43 @@ schema in [docs/DATABASE.md](docs/DATABASE.md), le procedure in
|
|||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
- **Versione:** 1.3.1 (in produzione su `main` la 1.3.0, tag `v1.3.0`) — il Calendario mostra solo i prossimi
|
- **Versione:** 1.4.2 in produzione su `main` (tag `v1.4.2`): ogni evento può avere un link Google
|
||||||
compleanni del mese; tolta la serie «Conferme 24h» con i badge «Risposta lampo» e «Mai un forfait». La 1.3.0
|
Maps del luogo, mostrato come «Apri su Maps» nelle schede (`m34`). Su `feature/gara` c'è del lavoro
|
||||||
portava la notifica di compleanno, gli obiettivi cliccabili, le presenze sui convocati, gli avvisi in Home
|
non ancora rilasciato (voto MVP, vedi «Lavoro in corso»). La 1.4.1: gli eventi con convocati si
|
||||||
ridisegnati e i caratteri più grandi. Dettaglio in [docs/CHANGELOG.md](docs/CHANGELOG.md).
|
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).
|
||||||
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
- **Backend:** Supabase proprietario (`kfkcldwncxqaixetsjes`). Lovable Cloud e il vecchio
|
||||||
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
Project Ref `hetycilxgkdmccelwerq` non si usano più.
|
||||||
- **Migration:** 42 file in `supabase/migrations/`, fino a `m31_notifica_compleanno`. Le prime 41 (fino a
|
- **Migration:** 49 file in `supabase/migrations/`, fino a `m38_nome_votato_badge_social`. In produzione
|
||||||
`m30_sollecito_solo_24h`) sono applicate in produzione (verificato con `npx supabase migration list` il
|
sono applicate 45 su 49, fino alla `m34_link_maps_evento` (verificato con
|
||||||
01/10/2026); la `m31` no, ancora da applicare (DD-047). La `m29` toglie
|
`npx supabase migration list --linked` il 26/10/2026: le uniche mancanti sono `m35`-`m38`, nessuna
|
||||||
`foto_path`: il codice che non la legge più (1.2.2) va rilasciato.
|
migration remota è assente dal repository). **Da applicare ancora: `m35`-`m38`**
|
||||||
|
(voto MVP e badge social, DD-051 e DD-052, branch `feature/gara`): `npx supabase db push` è rinviato
|
||||||
|
per scelta, non è stato eseguito. Vanno applicate subito prima del rilascio del codice: con le sole
|
||||||
|
migration il voto MVP dell'app in produzione (1.4.2) smette di funzionare, con il solo codice la
|
||||||
|
vista dei conteggi non esiste.
|
||||||
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
- **Worker delle notifiche** (`mailer/`): in funzione su un Raspberry Pi con Docker dal 29/09/2026
|
||||||
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
([docs/WORKER_EMAIL.md](docs/WORKER_EMAIL.md)). Il codice con l'interruttore «Email» in Profilo
|
||||||
è su `main` dalla 1.3.0.
|
è su `main` dalla 1.3.0.
|
||||||
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
- **Accesso:** il login Google è l'unica via d'ingresso; i permessi di amministrazione arrivano
|
||||||
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
solo da `user_roles`. Il collegamento degli account è un processo continuo: ogni giocatore si
|
||||||
collega al primo accesso (DD-018).
|
collega al primo accesso (DD-018).
|
||||||
- **Lavoro in corso:** niente di assegnato. Le voci aperte stanno in
|
- **Lavoro in corso:** branch `feature/gara` per le funzioni di gara (correzioni e nuove
|
||||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
funzioni; in futuro anche lo scouting). Primo lavoro: il voto MVP definitivo, segreto, a tempo e
|
||||||
|
tra presenti, con i pari merito (DD-051, DD-052). Non ancora in `main`, non rilasciato, versione
|
||||||
|
invariata. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||||
|
|
||||||
## Cose da sapere
|
## Cose da sapere
|
||||||
|
|
||||||
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
|
- Si lavora direttamente su `main` (DD-019); Cursor e Claude Code sono gli ambienti di sviluppo.
|
||||||
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
- Dev e produzione condividono lo stesso progetto Supabase: un account di prova che collega uno
|
||||||
slot lo occupa anche in produzione e va liberato da un admin.
|
slot lo occupa anche in produzione e va liberato da un admin.
|
||||||
- Lo slot `g18` «Beta Tester» è impostato come allenatore in produzione (24/09/2026). Numero 99
|
- Lo slot `g18` «Beta Tester» è un giocatore in produzione (dal 09/10/2026, prima era allenatore), numero 99 e
|
||||||
e ruolo «Palleggiatore» sono rimasti apposta per la 0.9.2: da svuotare da `/admin` se ancora
|
ruolo «Palleggiatore»; non è collegato a nessun account. Serve per provare le notifiche con un secondo utente.
|
||||||
presenti.
|
|
||||||
|
|
||||||
## Note
|
## Note
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,75 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
|
Quattro migration, da `m35` a `m38`, **non ancora applicate in produzione**.
|
||||||
|
|
||||||
|
### Modificato
|
||||||
|
|
||||||
|
- 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ù.
|
||||||
|
|
||||||
|
### 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.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
|
## [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
|
Il Calendario mostra solo i prossimi compleanni del mese, e le serie di presenze sono due invece di tre: sparisce
|
||||||
|
|||||||
+40
-39
@@ -12,21 +12,21 @@ 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
|
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).
|
sezione «Lettura» qui sotto; `anon` non arriva a nessuna di esse da M4 (DD-011).
|
||||||
|
|
||||||
| Tabella | Chi può scrivere |
|
| Tabella | Chi può scrivere |
|
||||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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_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) |
|
| `notifiche_email_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_notifica_email` e la gestisce solo il worker email con la service role (M22) |
|
||||||
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
| `preferenze_utente` | l'utente sulla propria riga (`auth_user_id = auth.uid()`), in lettura e scrittura (M22) |
|
||||||
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
| `promemoria_eventi_generati` | nessuno da client, né in lettura né in scrittura: la scrive solo la funzione `genera_promemoria_eventi()` dei cron (M23) |
|
||||||
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
| `compleanni_notificati` | Registro degli avvisi di compleanno già generati, per destinatario e giorno. | Chiave `(giocatore_id, giorno)`, `ON DELETE CASCADE` verso il giocatore (DD-029, DD-047, M31). Non è legato a un evento. Eliminare la notifica non fa rigenerare l'avviso. |
|
||||||
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
| `notifiche_push_coda` | nessuno da client, né in lettura né in scrittura: la riempie il trigger `accoda_push_promemoria` e la gestisce solo il worker con la service role (M24) |
|
||||||
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
|
||||||
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) |
|
||||||
| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (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()`.
|
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.
|
La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale.
|
||||||
@@ -74,13 +74,13 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
|||||||
|
|
||||||
## Eventi e presenze
|
## Eventi e presenze
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. Cancellare un evento pulisce a cascata, via trigger, tutte le tabelle collegate: `risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` (DD-029). `bonifica_dati_evento_orfani()` (solo service role) ripulisce eventuali righe orfane. |
|
| `eventi_app` | Eventi gestionali usati dall'app. | Modello in uso. `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. |
|
| `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. |
|
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello «nuovo» con autenticazione e vincoli, non adottato (DD-014). |
|
||||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||||
|
|
||||||
## Scout
|
## Scout
|
||||||
|
|
||||||
@@ -95,11 +95,11 @@ Colonne di `giocatori_squadra` che meritano una nota:
|
|||||||
Per tutte: un voto per votante e partita (per categoria nei badge social), auto-voto rifiutato,
|
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).
|
votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
||||||
|
|
||||||
| Tabella | Scopo | Note |
|
| Tabella | Scopo | Note |
|
||||||
| ------------------- | ---------------------------------- | --------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | Vincolo `mvp_no_autovoto`. |
|
| `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`. |
|
| `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`. |
|
| `badge_social_voti` | Voti social per i badge. | Vincolo `badge_social_no_autovoto`; `votato_nome` dal trigger sulla rosa (`m38`). |
|
||||||
|
|
||||||
## Turni e notifiche
|
## Turni e notifiche
|
||||||
|
|
||||||
@@ -122,11 +122,11 @@ votante e votato convocati all'evento (RLS, `evento_permette_voto()`, DD-027).
|
|||||||
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
- Non c'è una notifica alla sola creazione di un evento: conta l'avvicinarsi della data.
|
||||||
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
- Destinatari di un evento (`giocatori_destinatari_evento()`): giocatori attivi, i convocati o tutta
|
||||||
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
la rosa se `convocati` è vuoto; mai gli allenatori (DD-039).
|
||||||
- Il `CHECK` sul tipo ammette anche `compleanno` e `compleanno_auguri` (M31, DD-047), `turno_palloni_12h`, `_6h`, `_3h`, `turno_palloni_revocato` e
|
- 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
|
||||||
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
`sollecita_presenze_24h`, `_12h`, `_6h`, scritti dai job (DD-040). `turno_palloni` e
|
||||||
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
`sollecita_presenze` restano i tipi dei pulsanti manuali.
|
||||||
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
- Il trigger delle email salta tutti i tipi `turno_palloni*`; quello delle push accoda i tipi
|
||||||
automatici (compleanni compresi) ma non quelli dei pulsanti.
|
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).
|
||||||
|
|
||||||
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
### Code di invio (`notifiche_email_coda`, `notifiche_push_coda`)
|
||||||
|
|
||||||
@@ -152,16 +152,17 @@ Le notifiche automatiche nascono nel database, da job `pg_cron` che chiamano fun
|
|||||||
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
riservate alla service role (anon e utenti autenticati non possono chiamarle). Testi e regole sono in
|
||||||
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
[modules/notifiche.md](modules/notifiche.md); le decisioni in DD-037, DD-038 e DD-040.
|
||||||
|
|
||||||
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
| Funzione (migration) | Cosa fa | Job `pg_cron` |
|
||||||
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- |
|
||||||
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
| `genera_promemoria_eventi(tipo, finestra)` (M17, M23, M26) | Promemoria a 24 e 3 ore, con «oggi/domani», data, ora e luogo. **Non pianificata dalla M28** (DD-043): resta come funzione, nessun job la chiama. | `promemoria-eventi-24h` (ogni ora), `-3h` (15 min) |
|
||||||
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
| `genera_avvisi_palloni()` (M26, M27), con `genera_avvisi_palloni_fascia()` | Avviso del turno palloni a 3 ore (M27; in M26 erano 12, 6 e 3) per gli eventi con un incaricato confermato; poi chiama `genera_revoche_palloni()`. | `avvisi-palloni-automatici` (15 min) |
|
||||||
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
| `genera_revoche_palloni()` (M26) | Una revoca per chi aveva un avviso automatico e non è più destinatario, per eventi non ancora iniziati; guarda il registro. | (dentro `genera_avvisi_palloni()`) |
|
||||||
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
| `genera_solleciti_presenze()` (M26, M30), con `..._fascia()` | Sollecito a 24 ore (M30; in M26 erano 24, 12 e 6) ai destinatari dell'evento senza risposta o con «forse». | `solleciti-presenze-automatici` (15 min) |
|
||||||
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
| `genera_avvisi_compleanno(adesso)` (M31) | Dalle 8:00 (`Europe/Rome`) manda gli auguri a chi compie gli anni e l'avviso (solo titolo, senza età) agli altri membri attivi, un messaggio a testa; guarda il registro `compleanni_notificati`. `adesso` (default `now()`) serve ai test. 29 febbraio non gestito (DD-047). | `avvisi-compleanno` (ogni ora) |
|
||||||
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
| `avvisa_presenza_modificata()` (M32, M33) | Trigger su `risposte_presenze` (insert, update dello stato, delete): se lo stato cambia a 0-6 ore da una partita o un allenamento, scrive una notifica `presenza_modificata` per ogni admin e allenatore attivo (DD-050; la M32 solo admin), tranne il giocatore della presenza. Usa `nome_stato_presenza(stato)` per i testi. | trigger `risposte_presenze_avvisa_modifica` |
|
||||||
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
| `inizio_evento(data, ora)` (M26) | Istante d'inizio di un evento in fuso `Europe/Rome`; `NULL` se l'ora non è valida, e l'evento viene solo escluso. | — |
|
||||||
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
| `giocatori_destinatari_evento(evento)` (M17, M21, M25) | Giocatori attivi destinatari: i convocati, o tutta la rosa se `convocati` è vuoto; mai gli allenatori. | — |
|
||||||
|
| `accoda_notifica_email()`, `accoda_push_promemoria()` (M22, M24, M26) | Trigger su `notifiche_utente`: la mail salta i tipi `turno_palloni*`; la push accoda i tipi automatici (non quelli dei pulsanti). | — |
|
||||||
|
|
||||||
## Badge
|
## Badge
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
|||||||
|
|
||||||
- [x] Notifica di compleanno — auguri al festeggiato e avviso a tutti gli altri, ogni giorno dalle 8:00, su push,
|
- [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)
|
centro notifiche ed email ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-047)
|
||||||
|
- [x] Avviso «Presenza modificata» — una risposta che cambia a meno di 6 ore da una partita o un allenamento avvisa
|
||||||
|
admin e allenatori ([specifica](modules/notifiche.md#catalogo-delle-notifiche), DD-050)
|
||||||
|
|
||||||
## Prossimo
|
## Prossimo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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).
|
||||||
+52
-49
@@ -46,55 +46,58 @@ Una decisione sostituita non si cancella: si cambia il suo **Stato** e si rimand
|
|||||||
|
|
||||||
**Accettate**
|
**Accettate**
|
||||||
|
|
||||||
| ID | Titolo |
|
| ID | Titolo |
|
||||||
| ------------------- | ------------------------------------------- |
|
| ------------------- | --------------------------------------------------------- |
|
||||||
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
| [DD-001](DD-001.md) | Indipendenza da Lovable |
|
||||||
| [DD-002](DD-002.md) | Sviluppo document-first |
|
| [DD-002](DD-002.md) | Sviluppo document-first |
|
||||||
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
| [DD-004](DD-004.md) | Ogni versione aggiunge, non riscrive |
|
||||||
| [DD-005](DD-005.md) | Mobile-first |
|
| [DD-005](DD-005.md) | Mobile-first |
|
||||||
| [DD-006](DD-006.md) | AI solo se utile |
|
| [DD-006](DD-006.md) | AI solo se utile |
|
||||||
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
| [DD-007](DD-007.md) | Badge calcolati, non in DB |
|
||||||
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
| [DD-008](DD-008.md) | Gamification equa tra ruoli |
|
||||||
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
| [DD-009](DD-009.md) | CSI manuale, poi integrazione API |
|
||||||
| [DD-010](DD-010.md) | Niente storico certificati |
|
| [DD-010](DD-010.md) | Niente storico certificati |
|
||||||
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
| [DD-011](DD-011.md) | Auth reale prima del profilo |
|
||||||
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
| [DD-012](DD-012.md) | Rimandare la migrazione ID |
|
||||||
| [DD-013](DD-013.md) | Portabilità dello stack |
|
| [DD-013](DD-013.md) | Portabilità dello stack |
|
||||||
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
| [DD-015](DD-015.md) | Rosa da hardcoded a DB |
|
||||||
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
| [DD-016](DD-016.md) | Schema dati Profilo Giocatore |
|
||||||
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
| [DD-017](DD-017.md) | L'admin scrive al posto del giocatore |
|
||||||
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
| [DD-018](DD-018.md) | Collegamento automatico per email |
|
||||||
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
| [DD-019](DD-019.md) | Il branch lo decide l'utente |
|
||||||
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
| [DD-020](DD-020.md) | Test obbligatori e verdi |
|
||||||
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
| [DD-021](DD-021.md) | Molle interrompibili con motion |
|
||||||
| [DD-022](DD-022.md) | App solo chiara |
|
| [DD-022](DD-022.md) | App solo chiara |
|
||||||
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
| [DD-023](DD-023.md) | Scritture limitate per ruolo |
|
||||||
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
| [DD-024](DD-024.md) | Route di notifica autenticate |
|
||||||
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
| [DD-025](DD-025.md) | Promemoria palloni manuale |
|
||||||
| [DD-026](DD-026.md) | Payload push cifrato |
|
| [DD-026](DD-026.md) | Payload push cifrato |
|
||||||
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
| [DD-027](DD-027.md) | Voto limitato ai convocati |
|
||||||
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
| [DD-028](DD-028.md) | Soglia minima Media voto e MVP |
|
||||||
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
| [DD-029](DD-029.md) | Pulizia a cascata evento cancellato |
|
||||||
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
| [DD-030](DD-030.md) | Centro notifiche in-app |
|
||||||
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
| [DD-031](DD-031.md) | Nascita pubblica sincronizzata |
|
||||||
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
| [DD-032](DD-032.md) | Stagione CSI dalle date delle gare |
|
||||||
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
| [DD-033](DD-033.md) | Gestione eventi solo da calendario |
|
||||||
| [DD-034](DD-034.md) | Ruolo allenatore |
|
| [DD-034](DD-034.md) | Ruolo allenatore |
|
||||||
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
| [DD-035](DD-035.md) | Avviso certificati in Home |
|
||||||
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
| [DD-036](DD-036.md) | Notifiche email via Gmail |
|
||||||
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
| [DD-037](DD-037.md) | Promemoria evento senza ritorni |
|
||||||
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
| [DD-038](DD-038.md) | Tre canali per ogni notifica |
|
||||||
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
| [DD-039](DD-039.md) | Niente promemoria all'allenatore |
|
||||||
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
| [DD-040](DD-040.md) | Palloni e solleciti automatici |
|
||||||
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
| [DD-050](DD-050.md) | Avviso se una presenza cambia a meno di 6 ore dall'evento |
|
||||||
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
| [DD-051](DD-051.md) | Voto MVP definitivo e MVP a pari merito |
|
||||||
| [DD-047](DD-047.md) | Notifica di compleanno |
|
| [DD-052](DD-052.md) | Voto MVP segreto, nome dal database e chiusura |
|
||||||
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
| [DD-049](DD-049.md) | Caratteri più grandi in tutta l'app |
|
||||||
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
| [DD-048](DD-048.md) | Via la tessera dalla dashboard |
|
||||||
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
| [DD-047](DD-047.md) | Notifica di compleanno |
|
||||||
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
| [DD-046](DD-046.md) | Avviso certificati mancanti |
|
||||||
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
| [DD-045](DD-045.md) | Sollecito presenze: un solo avviso a 24 ore |
|
||||||
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
| [DD-044](DD-044.md) | Via la foto tessera dal profilo |
|
||||||
|
| [DD-043](DD-043.md) | Niente promemoria evento a 24 e 3 ore |
|
||||||
|
| [DD-042](DD-042.md) | Turno palloni: un solo avviso a 3 ore |
|
||||||
|
| [DD-041](DD-041.md) | Avviso certificati: giocatore a 30 giorni |
|
||||||
|
|
||||||
**In valutazione**
|
**In valutazione**
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ Cosa riceve l'allenatore, notifica per notifica (il catalogo completo, con i can
|
|||||||
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
| Turno palloni | no | I turni riguardano solo i giocatori. |
|
||||||
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
| Sondaggio pre-partita | no | Né la push né l'avviso in-app e per email (DD-038): il sondaggio non lo riguarda. |
|
||||||
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
| Compleanno (DD-047) | **sì** | Riceve l'avviso «Oggi è il compleanno di …» come tutta la squadra, e gli auguri nel giorno del proprio compleanno (la nascita è pubblica, DD-031). In-app, email e push come per i giocatori. |
|
||||||
|
| Presenza modificata (DD-050, `m33`) | **sì** | Riceve l'avviso quando un giocatore cambia, mette o ritira la risposta a meno di 6 ore da una partita o un allenamento, come gli admin. In-app, email e push. |
|
||||||
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
| Notifiche smart (badge, serie, obiettivi, social) | **no** | Le mostra `CelebrazioneBadge` per il giocatore selezionato, letto da `useIo()`, che cerca l'id in `useRosa()`, da cui l'allenatore è escluso: per lui `useIo()` è `null` e `useNotificheSmart()` non fa nulla. Non ha statistiche da cui calcolarle. |
|
||||||
|
|
||||||
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
Le email arrivano solo se il suo slot ha un account collegato e un'email registrata e se non ha
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ 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
|
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||||
`eventi_app`.
|
`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`)
|
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
|
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
|
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||||
|
|||||||
+38
-19
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
**Stato:** implementato
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/mvp-voti.ts`, `src/components/crapp/VotazioneMvp.tsx`
|
**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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ calcolato a runtime.
|
|||||||
## Dati
|
## Dati
|
||||||
|
|
||||||
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
Tabella `mvp_voti`, vincolo `UNIQUE (match_id, votante_id)` — un solo voto per giocatore per
|
||||||
partita, sovrascrivibile.
|
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.
|
||||||
|
|
||||||
`match_id` è l'**id dell'evento CrAPP**, non quello del referto CSI né dello Scout: la
|
`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
|
votazione non dipende più da nessuna delle due fonti (i voti scritti prima con l'id scout/CSI
|
||||||
@@ -27,44 +31,59 @@ 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()`
|
- 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
|
lo apre `ORE_ATTESA_MVP` (2) ore dopo `data`+`ora` dell'evento, prima di allora mostra solo
|
||||||
quando aprirà. Nessun legame con il risultato caricato.
|
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.
|
||||||
- Votano e sono votabili solo i **presenti** di quell'evento (`presente` o `ritardo` in
|
- 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.
|
`usePresenzeEvento`): chi non c'era ha il bottone disabilitato e non compare nell'elenco.
|
||||||
- `useVotaMvp()` fa upsert `onConflict: match_id, votante_id`: il voto è modificabile senza
|
- `useVotiMvp()` legge i conteggi dalla vista `mvp_voti_anonimi`; `useMieiVotiMvp()` legge dalla
|
||||||
limiti, senza storico.
|
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.
|
||||||
- Nessuno vota sé stesso: `VotazioneMvp.tsx` toglie il votante dall'elenco e il vincolo
|
- 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
|
`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.
|
direttamente su PostgREST, come già faceva `pagelle_no_autovoto` per le pagelle.
|
||||||
- `conteggioPartita()`/`vincitoriMvp()` richiedono un margine netto: in caso di parità,
|
- `mvpPartita()` restituisce i più votati: **in caso di parità in testa sono tutti MVP a pari merito**
|
||||||
nessun vincitore viene assegnato per quella partita finché non arrivano altri voti.
|
(DD-051). `vincitoriMvp()` ne unisce i nomi («Anna e Bruno») per Home e Classifica.
|
||||||
- `vincitoriMvp()`/`mvpVintiPerGiocatore()` richiedono anche un quorum minimo di voti totali
|
- `mvpPartita()` (quindi anche `vincitoriMvp()` e `mvpVintiPerGiocatore()`) richiede un quorum minimo di voti totali
|
||||||
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
sulla partita (`VOTI_MINIMI_MVP = 2`, `mvp-voti.ts`, DD-028): un solo voto non basta a
|
||||||
incoronare nessuno, nemmeno senza concorrenza.
|
incoronare nessuno, nemmeno senza concorrenza.
|
||||||
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita "vinta" con margine netto; il
|
- `mvpVintiPerGiocatore()` conta una vittoria per ogni partita in cui il giocatore è tra i più votati
|
||||||
risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
(anche a pari merito); il risultato alimenta il campo `mvp` del `Giocatore` in `useRosa()`, mostrato come StatTile
|
||||||
nel profilo e in home.
|
nel profilo e in home.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Limiti noti
|
## Limiti noti
|
||||||
|
|
||||||
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
|
- 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.
|
||||||
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
|
- 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
|
`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`).
|
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
|
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
|
il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
|
||||||
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
|
= tutta la rosa). Da `m36` (DD-051) la policy usa `voto_mvp_consentito()`, che aggiunge a quei
|
||||||
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
|
controlli: l'evento è una **partita**, votante e votato hanno risposto `presente` o `ritardo` e
|
||||||
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
|
sono passate **2 ore** dall'inizio e **non ancora altre 24** (`Europe/Rome`; un `ora` vuoto
|
||||||
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
|
vale mezzanotte). Sono le stesse
|
||||||
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
|
regole del pannello, ora imposte anche a chi scrive direttamente su PostgREST; l'attesa di 2 ore
|
||||||
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
|
e la chiusura stanno in `ORE_ATTESA_MVP`/`DURATA_VOTO_MVP_ORE` e negli intervalli della funzione
|
||||||
- In caso di parità, o sotto il quorum minimo di voti, nessun MVP viene assegnato per quella
|
(`m37`); un test unitario li tiene allineati. Gli
|
||||||
partita.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Evoluzioni possibili
|
## Evoluzioni possibili
|
||||||
|
|
||||||
- Introdurre una scadenza (es. la votazione si chiude N giorni dopo la partita).
|
- Nascondere i risultati finché la votazione non è chiusa.
|
||||||
|
- Una schermata per gli admin per correggere un voto (oggi solo dal database).
|
||||||
@@ -49,7 +49,7 @@ App su Vercel ──scrive──▶ notifiche_utente ──trigger──▶ noti
|
|||||||
|
|
||||||
## Quali notifiche e a chi
|
## Quali notifiche e a chi
|
||||||
|
|
||||||
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione). **Fa eccezione il turno palloni** (DD-040, dalla
|
**Ogni riga nuova di `notifiche_utente` genera una mail**: messaggio admin, sollecito presenze, sondaggio pre-partita, compleanno (DD-047: solo il titolo, senza corpo; la mail ha oggetto, link e avviso di disattivazione), presenza modificata (DD-050, ad admin e allenatori). **Fa eccezione il turno palloni** (DD-040, dalla
|
||||||
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
`m26`): per i tipi `turno_palloni`, `turno_palloni_12h`, `_6h`, `_3h` e `turno_palloni_revocato` il
|
||||||
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
trigger non accoda nulla, restano push e centro notifiche. Un tipo nuovo che in futuro finisse in quella tabella
|
||||||
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
viaggerebbe per email senza altro lavoro. Le notifiche già presenti quando si applica la migration
|
||||||
|
|||||||
+17
-14
@@ -44,10 +44,11 @@ quelle del turno palloni**, che restano solo push e in-app: DD-040).
|
|||||||
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
| 6 | **Sondaggio pre-partita** — «💩 Sondaggio pre-partita aperto» | Un admin, dalla pagina della partita. Route `apri-sondaggio` (vedi [Scout Live](scout-live.md)). | Push: tutti i dispositivi iscritti, tranne quelli degli allenatori. In-app ed email (tipo `sondaggio_cacche`): i giocatori attivi, allenatori esclusi, anche senza dispositivi iscritti. | sì | sì | sì |
|
||||||
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
| 7 | **Notifiche smart** — badge appena sbloccato, serie a un traguardo, obiettivo di squadra tra il 90 e il 100%, badge social vinto | L'app, mentre è aperta: sono calcolate in locale da `calcolaNotifiche()`. | Chi ha l'app aperta con uno slot giocatore in rosa selezionato; **mai l'allenatore**. Nessun invio verso altri dispositivi. | no (solo locale) | no | no |
|
||||||
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
| 8 | **Compleanno** — auguri al festeggiato, avviso agli altri | **Automatica**, **una volta al giorno, dalle 8:00** (fuso `Europe/Rome`) per chi compie gli anni quel giorno (DD-047, `m31`): job `pg_cron` ogni ora, funzione `genera_avvisi_compleanno()`, registro `compleanni_notificati`; tipi `compleanno_auguri` e `compleanno`, push dal worker. Nessun pulsante manuale. | **Tutti i membri attivi**, allenatori compresi: il festeggiato riceve gli auguri, gli altri l'avviso. Con più compleanni lo stesso giorno, un solo messaggio a testa (agli altri con tutti i nomi, a ogni festeggiato solo i propri auguri). | sì | sì | sì |
|
||||||
|
| 9 | **Presenza modificata** — «Presenza modificata: _Nome Cognome_» _(DD-050, `m32` e `m33`)_ | **Automatica**, a ogni cambio di una risposta in `risposte_presenze` (nuova, modificata o ritirata) quando mancano da 0 a 6 ore all'inizio di una partita o di un allenamento: trigger sul database, tipo `presenza_modificata`, una notifica per modifica, push dal worker. | Gli **admin e gli allenatori attivi** (DD-050, `m33`; la `m32` li dava ai soli admin). Mai il giocatore a cui appartiene la presenza. | sì | sì | sì |
|
||||||
|
|
||||||
Le notifiche generate dal server (righe 1-6 e 8) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
Le notifiche generate dal server (righe 1-6, 8 e 9) hanno tutti e tre i canali (DD-038), tranne il turno palloni (riga 4), che non manda email (DD-040). La
|
||||||
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
differenza sta in come parte la push: quelle dei pulsanti (3 e 6) la mandano le route dell'app, quelle
|
||||||
automatiche (4, 5 e 8; la 1 e la 2 non esistono più) nascono nel database e la manda il worker `mailer/` (vedi «Push dei
|
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
|
||||||
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
promemoria»). Il sondaggio riscrive la notifica esistente se l'admin preme di nuovo il pulsante per
|
||||||
lo stesso evento (vedi «Centro notifiche in-app»).
|
lo stesso evento (vedi «Centro notifiche in-app»).
|
||||||
|
|
||||||
@@ -75,6 +76,7 @@ disattivazione). Tra `<…>` i valori che cambiano a ogni invio; le date sono `G
|
|||||||
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
| 6 | Sondaggio pre-partita | `💩 Sondaggio pre-partita aperto` | `<partita> · ore <ora>. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.` |
|
||||||
| 8a | Compleanno, auguri al festeggiato | `Buon compleanno, <Nome>! 🎂` | Nessuno: solo il titolo |
|
| 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 |
|
| 8b | Compleanno, avviso agli altri | `Oggi è il compleanno di <Nome>` — con più festeggiati `<A> e <B>`, o `<A>, <B> e <C>`, in ordine alfabetico per nome | Nessuno: solo il titolo. L'età non compare né negli auguri né nell'avviso |
|
||||||
|
| 9 | Presenza modificata (DD-050) | `Presenza modificata: <Nome Cognome>` | `Evento: <titolo>`<br>`Data: <GG/MM/AAAA>, ore <ora>`<br>`Da: <stato prima>`<br>`A: <stato dopo>` — stati: presente, assente, forse, in ritardo, infortunato, nessuna risposta |
|
||||||
|
|
||||||
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
Le righe 4a-4c e 5 (e le 1-2, finché esistevano) nascono nelle funzioni SQL della migration `m26`
|
||||||
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
(`genera_promemoria_eventi`, `genera_avvisi_palloni_fascia`, `genera_revoche_palloni`,
|
||||||
@@ -102,7 +104,7 @@ dall'emoji. Non entrano nel centro notifiche e non generano email.
|
|||||||
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
- Creare, spostare, modificare o cancellare un evento. Dalla `m28` (DD-043) non parte nemmeno più un
|
||||||
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
promemoria prima dell'inizio: restano solo il turno palloni e il sollecito presenze.
|
||||||
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
- L'arrivo di un risultato, di una pagella o di un voto MVP.
|
||||||
- Un giocatore che risponde a un evento: nessun avviso a chi gestisce gli eventi.
|
- 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).
|
||||||
- Assenze e infortuni.
|
- Assenze e infortuni.
|
||||||
- La registrazione o il collegamento di un nuovo giocatore.
|
- La registrazione o il collegamento di un nuovo giocatore.
|
||||||
- Il certificato in scadenza: resta una card in Home (DD-035).
|
- Il certificato in scadenza: resta una card in Home (DD-035).
|
||||||
@@ -158,17 +160,18 @@ descrivono come funzionavano fino alla `m28`: la funzione esiste ancora ma nessu
|
|||||||
|
|
||||||
### Test che verificano il catalogo
|
### Test che verificano il catalogo
|
||||||
|
|
||||||
| Cosa | Test |
|
| Cosa | Test |
|
||||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
| Promemoria 24 e 3 ore (funzione ancora presente ma non pianificata, M28): destinatari (convocati, tutti i giocatori attivi se `convocati` è vuoto, mai l'allenatore), deduplica, eliminazione, coda push ed email | `test/integration/destinatari-notifiche.test.ts`, `promemoria-eventi.test.ts`, `push-promemoria.test.ts`, `notifiche-email.test.ts`; niente job pianificati: `notifiche-automatiche.test.ts` (M28) |
|
||||||
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
| Messaggio dello staff: push a tutti i dispositivi (allenatore compreso) o a uno solo, in-app a tutta la rosa attiva, riservato agli admin | `destinatari-notifiche.test.ts`, `notifiche-utente.test.ts` |
|
||||||
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
| Turno palloni: incaricato e chi li aveva prima, solo allenamenti e partite, riservato agli admin | `destinatari-notifiche.test.ts`, `unit/palloni-core.test.ts` |
|
||||||
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
| Sollecito presenze: chi non ha risposto o ha detto «forse», mai l'allenatore; permesso ad allenatore e admin, non ai giocatori | `destinatari-notifiche.test.ts`, `unit/presenze.test.ts` |
|
||||||
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
| Sondaggio pre-partita: push senza allenatori, avviso in-app ed email ai giocatori attivi, `upsert`, riservato agli admin | `destinatari-notifiche.test.ts`, `sondaggio-notifiche.test.ts`, `unit/cacche.test.ts` |
|
||||||
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
| Promemoria con «oggi/domani», turno palloni a 3 ore e revoca, solleciti a 24 ore (manuale compreso): finestre, destinatari, una sola volta, registro (DD-040, `m26`; palloni a 3 ore: DD-042, solleciti a 24 ore: DD-045, `m30`; `m27`) | `integration/notifiche-automatiche.test.ts`, `promemoria-eventi.test.ts` (testo oggi/domani), `destinatari-notifiche.test.ts` (pulsanti), `unit/palloni-core.test.ts`, `unit/presenze.test.ts` |
|
||||||
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
| Compleanno: ora (anche con l'ora legale), festeggiato e altri, più compleanni lo stesso giorno, una sola volta anche se eliminata, push ed email in coda, 29 febbraio, job pianificato (DD-047, `m31`) | `integration/notifiche-compleanno.test.ts` |
|
||||||
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
| Presenza modificata: finestra di 0-6 ore, solo partite e allenamenti, admin e allenatori attivi (non gli inattivi né chi non ha slot, una sola notifica a chi ha due ruoli), mai il giocatore stesso, tutti gli stati, nessun avviso se lo stato non cambia o l'evento è cancellato, push ed email in coda (DD-050, `m32`-`m33`) | `integration/presenza-modificata.test.ts`, `unit/notifiche-utente.test.ts` (tipo) |
|
||||||
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
| Worker: esiti, backoff, tetto giornaliero, iscrizioni scadute | `unit/mailer-core.test.ts` |
|
||||||
|
| L'allenatore è fuori dalla rosa di gioco | `unit/giocatori-squadra.test.ts` (`inRosa`) |
|
||||||
|
|
||||||
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
Le push dei test vanno a un servizio push finto in locale: si verifica **a chi** arrivano, non la
|
||||||
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
consegna su un telefono. Non ha un test l'esclusione dell'allenatore dalle **notifiche smart**: la
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.3.1",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.3.1",
|
"version": "1.4.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.111.0",
|
"@supabase/supabase-js": "^2.111.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.3.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Link } from "@tanstack/react-router";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Card } from "@/components/crapp/ui-bits";
|
import { Card } from "@/components/crapp/ui-bits";
|
||||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||||
import type { Evento } from "@/lib/eventi";
|
import { eConvocato, rosaConvocata, type Evento } from "@/lib/eventi";
|
||||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||||
@@ -75,7 +75,8 @@ export function EventoCard({
|
|||||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||||
const io = useGiocatoreInCampo();
|
const io = useGiocatoreInCampo();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const rosa = squadra.filter(inRosa);
|
// Solo i convocati contano e rispondono (vuoto = tutta la rosa).
|
||||||
|
const rosa = rosaConvocata(squadra.filter(inRosa), evento.convocati);
|
||||||
const stato = io ? risposte[io.id] : undefined;
|
const stato = io ? risposte[io.id] : undefined;
|
||||||
const presentiVeri = rosa.filter(
|
const presentiVeri = rosa.filter(
|
||||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||||
@@ -187,7 +188,7 @@ export function EventoCard({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{io ? (
|
{io && eConvocato(evento, io.id) ? (
|
||||||
<div
|
<div
|
||||||
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
className={cn("pointer-events-auto flex w-full gap-1", metaStato ? "mt-2.5" : "mt-3")}
|
||||||
role="group"
|
role="group"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
|||||||
import { Avatar } from "@/components/crapp/Avatar";
|
import { Avatar } from "@/components/crapp/Avatar";
|
||||||
import { Barra } from "@/components/motion/Barra";
|
import { Barra } from "@/components/motion/Barra";
|
||||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||||
import type { EventoTipo } from "@/lib/eventi";
|
import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
|
||||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||||
@@ -20,9 +20,12 @@ export function RosaPresenze({
|
|||||||
eventoId,
|
eventoId,
|
||||||
data,
|
data,
|
||||||
tipo = "allenamento",
|
tipo = "allenamento",
|
||||||
|
convocati = [],
|
||||||
}: {
|
}: {
|
||||||
eventoId: string;
|
eventoId: string;
|
||||||
data: string;
|
data: string;
|
||||||
|
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
|
||||||
|
convocati?: string[];
|
||||||
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||||
tipo?: EventoTipo;
|
tipo?: EventoTipo;
|
||||||
}) {
|
}) {
|
||||||
@@ -34,7 +37,8 @@ export function RosaPresenze({
|
|||||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||||
const mittente = useGiocatoreBase();
|
const mittente = useGiocatoreBase();
|
||||||
const puoSollecitare = usePuoGestireEventi();
|
const puoSollecitare = usePuoGestireEventi();
|
||||||
const rosa = useAnagraficaRosa();
|
const anagrafica = useAnagraficaRosa();
|
||||||
|
const rosa = rosaConvocata(anagrafica, convocati);
|
||||||
const [sollecito, setSollecito] = useState(false);
|
const [sollecito, setSollecito] = useState(false);
|
||||||
const passato = data < dataOggi();
|
const passato = data < dataOggi();
|
||||||
|
|
||||||
@@ -97,7 +101,7 @@ export function RosaPresenze({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{io ? (
|
{io && rosa.some((g) => g.id === io.id) ? (
|
||||||
<div className="mt-4 border-t border-border pt-3">
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
La tua risposta
|
La tua risposta
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ import type { Evento } from "@/lib/eventi";
|
|||||||
import {
|
import {
|
||||||
conteggioPartita,
|
conteggioPartita,
|
||||||
mioVoto,
|
mioVoto,
|
||||||
|
mvpPartita,
|
||||||
ORE_ATTESA_MVP,
|
ORE_ATTESA_MVP,
|
||||||
|
useMieiVotiMvp,
|
||||||
useVotaMvp,
|
useVotaMvp,
|
||||||
useVotiMvp,
|
useVotiMvp,
|
||||||
|
VOTI_MINIMI_MVP,
|
||||||
votoMvpAperto,
|
votoMvpAperto,
|
||||||
type VotoMvp,
|
votoMvpChiuso,
|
||||||
|
DURATA_VOTO_MVP_ORE,
|
||||||
|
type VotoMvpPubblico,
|
||||||
} from "@/lib/mvp-voti";
|
} from "@/lib/mvp-voti";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pannello di votazione MVP di una partita: un voto a testa, modificabile.
|
* Pannello di votazione MVP di una partita: un voto a testa, definitivo. Con una parità in
|
||||||
|
* testa gli MVP sono più d'uno.
|
||||||
*
|
*
|
||||||
* I voti sono legati all'evento CrAPP, non al referto CSI né allo scout: si vota anche
|
* 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
|
* senza risultato caricato, dalle due ore dopo il fischio d'inizio. Votano e sono votabili
|
||||||
@@ -28,20 +34,22 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||||
const io = useGiocatoreInCampo();
|
const io = useGiocatoreInCampo();
|
||||||
const voti = useVotiMvp();
|
const voti = useVotiMvp();
|
||||||
|
const miei = useMieiVotiMvp(io?.id);
|
||||||
const vota = useVotaMvp();
|
const vota = useVotaMvp();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const { risposte } = usePresenzeEvento(evento.id);
|
const { risposte } = usePresenzeEvento(evento.id);
|
||||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||||
const [aperto, setAperto] = useState(false);
|
const [aperto, setAperto] = useState(false);
|
||||||
|
const [scelto, setScelto] = useState<{ id: string; nome: string } | null>(null);
|
||||||
|
|
||||||
const tutti: VotoMvp[] = voti.data ?? [];
|
const tutti: VotoMvpPubblico[] = voti.data ?? [];
|
||||||
const conteggio = conteggioPartita(tutti, matchId);
|
const conteggio = conteggioPartita(tutti, matchId);
|
||||||
const mio = io ? mioVoto(tutti, matchId, io.id) : null;
|
const mio = io ? mioVoto(miei.data ?? [], matchId, io.id) : null;
|
||||||
|
const chiuso = votoMvpChiuso(evento.data, evento.ora);
|
||||||
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
|
const totale = conteggio.reduce((s, c) => s + c.voti, 0);
|
||||||
const testa = conteggio[0];
|
const mvp = mvpPartita(tutti, matchId);
|
||||||
const pareggio = conteggio.length > 1 && conteggio[1]!.voti === testa?.voti;
|
const puoVotare = !!io && !mio && !chiuso && presente(io.id);
|
||||||
const puoVotare = !!io && presente(io.id);
|
|
||||||
|
|
||||||
if (!votoMvpAperto(evento.data, evento.ora)) {
|
if (!votoMvpAperto(evento.data, evento.ora)) {
|
||||||
return (
|
return (
|
||||||
@@ -66,9 +74,10 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
votato_nome: nome,
|
votato_nome: nome,
|
||||||
});
|
});
|
||||||
setAperto(false);
|
setAperto(false);
|
||||||
|
setScelto(null);
|
||||||
toast.success(`Voto MVP registrato: ${nome}`);
|
toast.success(`Voto MVP registrato: ${nome}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Voto non riuscito, riprova");
|
toast.error("Voto non riuscito: se hai già votato, il voto non si può cambiare");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,58 +87,87 @@ export function VotazioneMvp({ evento }: { evento: Evento }) {
|
|||||||
<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-[13px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
<Vote className="h-3.5 w-3.5" /> Voto MVP · {totale} {totale === 1 ? "voto" : "voti"}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{mio || chiuso ? null : (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setAperto((v) => !v)}
|
type="button"
|
||||||
disabled={!puoVotare}
|
onClick={() => setAperto((v) => !v)}
|
||||||
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
disabled={!puoVotare}
|
||||||
>
|
className="rounded-full bg-accent px-3 py-1 text-[13px] font-bold uppercase text-accent-foreground disabled:opacity-50"
|
||||||
{mio ? "Cambia voto" : "Vota"}
|
>
|
||||||
</button>
|
Vota
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-[13px]">
|
<p className="mt-2 text-[13px]">
|
||||||
{testa && !pareggio ? (
|
{mvp.length > 0 ? (
|
||||||
<span className="inline-flex items-center gap-1 font-bold">
|
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 font-bold">
|
||||||
<Crown className="h-3.5 w-3.5 text-warning" /> {testa.nome} ({testa.voti})
|
{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>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{totale === 0 ? "Nessun voto: MVP da eleggere" : "Parità: servono altri voti"}
|
{totale === 0
|
||||||
|
? "Nessun voto: MVP da eleggere"
|
||||||
|
: `Servono almeno ${VOTI_MINIMI_MVP} voti per eleggere l'MVP`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{mio ? (
|
{mio ? (
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">Hai votato {mio.votato_nome}</p>
|
<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>
|
||||||
) : !puoVotare ? (
|
) : !puoVotare ? (
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
Vota chi era presente a questa partita.
|
Vota chi era presente a questa partita.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : (
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
|
Il voto è segreto e resta aperto {DURATA_VOTO_MVP_ORE} ore.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{aperto ? (
|
{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
|
<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). */}
|
anche dal vincolo `mvp_no_autovoto` (M12). */}
|
||||||
{rosa
|
{rosa
|
||||||
.filter((g) => g.id !== io?.id)
|
.filter((g) => g.id !== io?.id)
|
||||||
.map((g) => (
|
.map((g) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={vota.isPending}
|
disabled={vota.isPending}
|
||||||
onClick={() => invia(g.id, nomeCompleto(g))}
|
onClick={() => setScelto({ id: g.id, nome: nomeCompleto(g) })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
"truncate rounded-xl px-2.5 py-2 text-left text-[13px] font-semibold transition-colors",
|
||||||
mio?.votato_id === g.id
|
scelto?.id === g.id
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-card text-foreground",
|
: "bg-card text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{nomeCompleto(g)}
|
{nomeCompleto(g)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
) : conteggio.length > 0 ? (
|
) : conteggio.length > 0 ? (
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{conteggio.map((c) => (
|
{conteggio.map((c) => (
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export type Database = {
|
|||||||
data: string;
|
data: string;
|
||||||
id: string;
|
id: string;
|
||||||
luogo: string;
|
luogo: string;
|
||||||
|
link_maps: string | null;
|
||||||
note: string;
|
note: string;
|
||||||
ora: string;
|
ora: string;
|
||||||
pagelle_chiuse: boolean;
|
pagelle_chiuse: boolean;
|
||||||
@@ -160,6 +161,7 @@ export type Database = {
|
|||||||
data: string;
|
data: string;
|
||||||
id: string;
|
id: string;
|
||||||
luogo?: string;
|
luogo?: string;
|
||||||
|
link_maps?: string | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
ora?: string;
|
ora?: string;
|
||||||
pagelle_chiuse?: boolean;
|
pagelle_chiuse?: boolean;
|
||||||
@@ -175,6 +177,7 @@ export type Database = {
|
|||||||
data?: string;
|
data?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
luogo?: string;
|
luogo?: string;
|
||||||
|
link_maps?: string | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
ora?: string;
|
ora?: string;
|
||||||
pagelle_chiuse?: boolean;
|
pagelle_chiuse?: boolean;
|
||||||
@@ -622,7 +625,14 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
Views: {
|
Views: {
|
||||||
[_ in never]: never;
|
mvp_voti_anonimi: {
|
||||||
|
Row: {
|
||||||
|
match_id: string | null;
|
||||||
|
votato_id: string | null;
|
||||||
|
votato_nome: string | null;
|
||||||
|
};
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
has_role: {
|
has_role: {
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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";
|
import { daRiga, type Evento, type RigaEvento } from "./eventi";
|
||||||
|
|
||||||
const COLONNE =
|
const COLONNE =
|
||||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse";
|
||||||
|
|
||||||
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
/** Lettura eventi lato server (route API): stessa conversione del client. */
|
||||||
export async function leggiEventi(): Promise<Evento[]> {
|
export async function leggiEventi(): Promise<Evento[]> {
|
||||||
|
|||||||
+44
-3
@@ -9,6 +9,8 @@ export type Evento = {
|
|||||||
tipo: EventoTipo;
|
tipo: EventoTipo;
|
||||||
titolo: string;
|
titolo: string;
|
||||||
luogo: string;
|
luogo: string;
|
||||||
|
/** Link Google Maps del luogo; assente o vuoto = nessun link. */
|
||||||
|
linkMaps?: string | undefined;
|
||||||
data: string;
|
data: string;
|
||||||
ora: string;
|
ora: string;
|
||||||
note: string;
|
note: string;
|
||||||
@@ -30,6 +32,7 @@ export type RigaEvento = {
|
|||||||
tipo: string;
|
tipo: string;
|
||||||
titolo: string;
|
titolo: string;
|
||||||
luogo: string;
|
luogo: string;
|
||||||
|
link_maps?: string | null;
|
||||||
data: string;
|
data: string;
|
||||||
ora: string;
|
ora: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -47,6 +50,7 @@ export function daRiga(r: RigaEvento): Evento {
|
|||||||
tipo: (r.tipo as EventoTipo) ?? "evento",
|
tipo: (r.tipo as EventoTipo) ?? "evento",
|
||||||
titolo: r.titolo,
|
titolo: r.titolo,
|
||||||
luogo: r.luogo ?? "",
|
luogo: r.luogo ?? "",
|
||||||
|
linkMaps: r.link_maps ?? "",
|
||||||
data: r.data,
|
data: r.data,
|
||||||
ora: r.ora ?? "",
|
ora: r.ora ?? "",
|
||||||
note: r.note ?? "",
|
note: r.note ?? "",
|
||||||
@@ -59,7 +63,7 @@ export function daRiga(r: RigaEvento): Evento {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLONNE =
|
const COLONNE =
|
||||||
"id, tipo, titolo, luogo, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
"id, tipo, titolo, luogo, link_maps, data, ora, note, convocati, campionato, casa, pagelle_chiuse, creato_il";
|
||||||
|
|
||||||
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
/** Categoria mostrata in interfaccia: le amichevoli sono partite fuori campionato. */
|
||||||
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
export type CategoriaEvento = "allenamento" | "partita" | "amichevole" | "evento";
|
||||||
@@ -76,6 +80,17 @@ export function daCategoria(c: CategoriaEvento): Pick<Evento, "tipo" | "campiona
|
|||||||
return { tipo: c, campionato: false };
|
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;
|
export const EVENTI_KEY = ["eventi"] as const;
|
||||||
|
|
||||||
async function fetchEventi(): Promise<Evento[]> {
|
async function fetchEventi(): Promise<Evento[]> {
|
||||||
@@ -90,6 +105,25 @@ export function useEventi() {
|
|||||||
return { ...query, eventi: query.data ?? [] };
|
return { ...query, eventi: query.data ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Il giocatore è tra i convocati (elenco vuoto = tutta la rosa). */
|
||||||
|
export function eConvocato(evento: Pick<Evento, "convocati">, giocatoreId: string): boolean {
|
||||||
|
return evento.convocati.length === 0 || evento.convocati.includes(giocatoreId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chi vede un evento: i convocati; se `convocati` è vuoto, tutta la rosa. Chi gestisce gli
|
||||||
|
* eventi (admin e allenatori) li vede sempre, convocato o no.
|
||||||
|
*/
|
||||||
|
export function eventoVisibile(
|
||||||
|
evento: Pick<Evento, "convocati">,
|
||||||
|
giocatoreId: string | null,
|
||||||
|
vedeTutto: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (vedeTutto) return true;
|
||||||
|
if (evento.convocati.length === 0) return true;
|
||||||
|
return giocatoreId !== null && eConvocato(evento, giocatoreId);
|
||||||
|
}
|
||||||
|
|
||||||
export function useEvento(id: string) {
|
export function useEvento(id: string) {
|
||||||
const { eventi, ...resto } = useEventi();
|
const { eventi, ...resto } = useEventi();
|
||||||
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
return { ...resto, evento: eventi.find((e) => e.id === id) ?? null };
|
||||||
@@ -105,6 +139,7 @@ export function eventoVuoto(): Evento {
|
|||||||
tipo: "allenamento",
|
tipo: "allenamento",
|
||||||
titolo: "",
|
titolo: "",
|
||||||
luogo: "",
|
luogo: "",
|
||||||
|
linkMaps: "",
|
||||||
data: new Date().toISOString().slice(0, 10),
|
data: new Date().toISOString().slice(0, 10),
|
||||||
ora: "20:30",
|
ora: "20:30",
|
||||||
note: "",
|
note: "",
|
||||||
@@ -125,6 +160,7 @@ export function useSalvaEvento() {
|
|||||||
tipo: evento.tipo,
|
tipo: evento.tipo,
|
||||||
titolo: evento.titolo,
|
titolo: evento.titolo,
|
||||||
luogo: evento.luogo,
|
luogo: evento.luogo,
|
||||||
|
link_maps: evento.linkMaps?.trim() || null,
|
||||||
data: evento.data,
|
data: evento.data,
|
||||||
ora: evento.ora,
|
ora: evento.ora,
|
||||||
note: evento.note,
|
note: evento.note,
|
||||||
@@ -198,6 +234,11 @@ export function compleanniEventi(
|
|||||||
|
|
||||||
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
/** Rosa convocata per un evento: se non specificata vale tutta la rosa. */
|
||||||
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
export function convocatiEvento(evento: Evento | null, rosa: Giocatore[]) {
|
||||||
if (!evento || evento.convocati.length === 0) return rosa;
|
return rosaConvocata(rosa, evento?.convocati ?? []);
|
||||||
return rosa.filter((g) => evento.convocati.includes(g.id));
|
}
|
||||||
|
|
||||||
|
/** Restringe una rosa ai convocati indicati: elenco vuoto = tutta la rosa. */
|
||||||
|
export function rosaConvocata<T extends { id: string }>(rosa: T[], convocati: string[]): T[] {
|
||||||
|
if (convocati.length === 0) return rosa;
|
||||||
|
return rosa.filter((g) => convocati.includes(g.id));
|
||||||
}
|
}
|
||||||
+79
-47
@@ -8,52 +8,91 @@ export type VotoMvp = {
|
|||||||
votato_nome: string;
|
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 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. */
|
/** Ore da aspettare dall'inizio della partita prima di poter votare l'MVP. */
|
||||||
export const ORE_ATTESA_MVP = 2;
|
export const ORE_ATTESA_MVP = 2;
|
||||||
|
|
||||||
/** Il voto MVP apre 2 ore dopo l'inizio della partita e da lì resta aperto. */
|
/** 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. */
|
||||||
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
|
export function votoMvpAperto(data: string, ora: string, adesso = new Date()): boolean {
|
||||||
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
const inizio = new Date(`${data}T${ora || "00:00"}:00`);
|
||||||
if (Number.isNaN(inizio.getTime())) return false;
|
if (Number.isNaN(inizio.getTime())) return false;
|
||||||
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
|
return adesso.getTime() >= inizio.getTime() + ORE_ATTESA_MVP * 60 * 60_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tutti i voti MVP della squadra (poche righe, si carica tutto).
|
/** 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).
|
||||||
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
* Nessun polling: la lista si aggiorna dopo il proprio voto o al rientro sull'app. */
|
||||||
export function useVotiMvp() {
|
export function useVotiMvp() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: CHIAVE,
|
queryKey: CHIAVE,
|
||||||
staleTime: 10 * 60_000,
|
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[]> => {
|
queryFn: async (): Promise<VotoMvp[]> => {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("mvp_voti")
|
.from("mvp_voti")
|
||||||
.select("match_id, votante_id, votato_id, votato_nome");
|
.select("match_id, votante_id, votato_id, votato_nome")
|
||||||
|
.eq("votante_id", votanteId!);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return (data ?? []) as VotoMvp[];
|
return (data ?? []) as VotoMvp[];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Il voto è definitivo: un secondo voto sulla stessa partita viene rifiutato dal database. */
|
||||||
export function useVotaMvp() {
|
export function useVotaMvp() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (voto: VotoMvp) => {
|
mutationFn: async (voto: VotoMvp) => {
|
||||||
const { error } = await supabase
|
const { error } = await supabase.from("mvp_voti").insert(voto);
|
||||||
.from("mvp_voti")
|
|
||||||
.upsert(voto, { onConflict: "match_id,votante_id" });
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return voto;
|
return voto;
|
||||||
},
|
},
|
||||||
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
// Aggiorna la cache localmente: nessuna rilettura dal database.
|
||||||
onSuccess: (voto) => {
|
onSuccess: (voto) => {
|
||||||
qc.setQueryData<VotoMvp[]>(CHIAVE, (prec) => {
|
qc.setQueryData<VotoMvp[]>(chiaveMiei(voto.votante_id), (prec) => [
|
||||||
const altri = (prec ?? []).filter(
|
...(prec ?? []).filter((v) => v.match_id !== voto.match_id),
|
||||||
(v) => !(v.match_id === voto.match_id && v.votante_id === voto.votante_id),
|
voto,
|
||||||
);
|
]);
|
||||||
return [...altri, voto];
|
qc.setQueryData<VotoMvpPubblico[]>(CHIAVE, (prec) => [
|
||||||
});
|
...(prec ?? []),
|
||||||
|
{ match_id: voto.match_id, votato_id: voto.votato_id, votato_nome: voto.votato_nome },
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,7 +103,7 @@ export type ConteggioMvp = { id: string; nome: string; voti: number };
|
|||||||
export const VOTI_MINIMI_MVP = 2;
|
export const VOTI_MINIMI_MVP = 2;
|
||||||
|
|
||||||
/** Conteggio voti di una partita, dal più votato. */
|
/** Conteggio voti di una partita, dal più votato. */
|
||||||
export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp[] {
|
export function conteggioPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
|
||||||
const map = new Map<string, ConteggioMvp>();
|
const map = new Map<string, ConteggioMvp>();
|
||||||
for (const v of voti) {
|
for (const v of voti) {
|
||||||
if (v.match_id !== matchId) continue;
|
if (v.match_id !== matchId) continue;
|
||||||
@@ -75,26 +114,29 @@ export function conteggioPartita(voti: VotoMvp[], matchId: string): ConteggioMvp
|
|||||||
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
return [...map.values()].sort((a, b) => b.voti - a.voti || a.nome.localeCompare(b.nome));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vincitore per ogni partita votata: matchId -> nome MVP. */
|
/**
|
||||||
export function vincitoriMvp(voti: VotoMvp[]): Record<string, string> {
|
* MVP di una partita: chi ha il massimo dei voti. Con una parità in testa sono tutti MVP a pari
|
||||||
const perMatch = new Map<string, VotoMvp[]>();
|
* merito. Sotto il quorum (`VOTI_MINIMI_MVP` voti totali) nessuno lo è ancora.
|
||||||
for (const v of voti) {
|
*/
|
||||||
const arr = perMatch.get(v.match_id) ?? [];
|
export function mvpPartita(voti: VotoMvpPubblico[], matchId: string): ConteggioMvp[] {
|
||||||
arr.push(v);
|
const top = conteggioPartita(voti, matchId);
|
||||||
perMatch.set(v.match_id, arr);
|
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> {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [matchId] of perMatch) {
|
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||||
const top = conteggioPartita(voti, matchId);
|
const mvp = mvpPartita(voti, matchId);
|
||||||
const totaleVoti = top.reduce((s, c) => s + c.voti, 0);
|
if (mvp.length > 0) out[matchId] = elencoNomi(mvp.map((c) => c.nome));
|
||||||
// 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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -104,24 +146,14 @@ export function mioVoto(voti: VotoMvp[], matchId: string, votanteId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVP vinti per giocatore, contando una vittoria per partita votata (una partita in pareggio
|
* MVP vinti per giocatore: una vittoria per ogni partita in cui è tra i più votati (in caso di
|
||||||
* al vertice, o sotto il quorum minimo di voti, non assegna vittorie a nessuno). Senza voti
|
* parità in testa il titolo va a tutti i pari merito; sotto il quorum a nessuno). Senza voti
|
||||||
* restituisce una mappa vuota.
|
* restituisce una mappa vuota.
|
||||||
*/
|
*/
|
||||||
export function mvpVintiPerGiocatore(voti: VotoMvp[]): Record<string, number> {
|
export function mvpVintiPerGiocatore(voti: VotoMvpPubblico[]): Record<string, number> {
|
||||||
const out: Record<string, number> = {};
|
const out: Record<string, number> = {};
|
||||||
const matchIds = new Set(voti.map((v) => v.match_id));
|
for (const matchId of new Set(voti.map((v) => v.match_id))) {
|
||||||
for (const matchId of matchIds) {
|
for (const { id } of mvpPartita(voti, matchId)) out[id] = (out[id] ?? 0) + 1;
|
||||||
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,8 @@ export type TipoNotifica =
|
|||||||
| "sollecita_presenze_6h"
|
| "sollecita_presenze_6h"
|
||||||
| "sondaggio_cacche"
|
| "sondaggio_cacche"
|
||||||
| "compleanno"
|
| "compleanno"
|
||||||
| "compleanno_auguri";
|
| "compleanno_auguri"
|
||||||
|
| "presenza_modificata";
|
||||||
|
|
||||||
export type NotificaUtente = {
|
export type NotificaUtente = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ function AllenamentoDetail() {
|
|||||||
<span className="ml-4 inline-flex items-center gap-2">
|
<span className="ml-4 inline-flex items-center gap-2">
|
||||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
@@ -93,7 +103,12 @@ function AllenamentoDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
<RosaPresenze
|
||||||
|
eventoId={evento.id}
|
||||||
|
data={evento.data}
|
||||||
|
tipo={evento.tipo}
|
||||||
|
convocati={evento.convocati}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { molla, proietta } from "@/lib/molla";
|
|||||||
import { useMotoRidotto } from "@/lib/motion";
|
import { useMotoRidotto } from "@/lib/motion";
|
||||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, type Evento } from "@/lib/eventi";
|
||||||
|
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
@@ -56,7 +57,7 @@ function Calendario() {
|
|||||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||||
const puoGestire = usePuoGestireEventi();
|
const puoGestire = usePuoGestireEventi();
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventiVisibili();
|
||||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||||
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||||
|
|||||||
+26
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
|
||||||
@@ -21,8 +22,8 @@ import {
|
|||||||
categoriaEvento,
|
categoriaEvento,
|
||||||
daCategoria,
|
daCategoria,
|
||||||
eventoVuoto,
|
eventoVuoto,
|
||||||
|
linkMapsValido,
|
||||||
useEliminaEvento,
|
useEliminaEvento,
|
||||||
useEventi,
|
|
||||||
useSalvaEvento,
|
useSalvaEvento,
|
||||||
type CategoriaEvento,
|
type CategoriaEvento,
|
||||||
type Evento,
|
type Evento,
|
||||||
@@ -65,7 +66,7 @@ function GestioneEventi() {
|
|||||||
const io = useGiocatoreBase();
|
const io = useGiocatoreBase();
|
||||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||||
const puoGestire = usePuoGestireEventi();
|
const puoGestire = usePuoGestireEventi();
|
||||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
const { eventi, isPending, isError, error, refetch } = useEventiVisibili();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
const salva = useSalvaEvento();
|
const salva = useSalvaEvento();
|
||||||
const elimina = useEliminaEvento();
|
const elimina = useEliminaEvento();
|
||||||
@@ -111,6 +112,12 @@ function GestioneEventi() {
|
|||||||
toast.error("Serve un titolo per l'evento");
|
toast.error("Serve un titolo per l'evento");
|
||||||
return;
|
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) {
|
if (modificaEsistente) {
|
||||||
setConfermaModifica(true);
|
setConfermaModifica(true);
|
||||||
return;
|
return;
|
||||||
@@ -121,7 +128,11 @@ function GestioneEventi() {
|
|||||||
async function conferma() {
|
async function conferma() {
|
||||||
if (!bozza) return;
|
if (!bozza) return;
|
||||||
try {
|
try {
|
||||||
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
|
await salva.mutateAsync({
|
||||||
|
...bozza,
|
||||||
|
titolo: bozza.titolo.trim(),
|
||||||
|
linkMaps: (bozza.linkMaps ?? "").trim(),
|
||||||
|
});
|
||||||
toast.success("Evento salvato");
|
toast.success("Evento salvato");
|
||||||
setBozza(null);
|
setBozza(null);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -373,6 +384,18 @@ function GestioneEventi() {
|
|||||||
/>
|
/>
|
||||||
</Campo>
|
</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">
|
<Campo label="Note">
|
||||||
<textarea
|
<textarea
|
||||||
value={bozza.note}
|
value={bozza.note}
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ function EventoDetail() {
|
|||||||
<span className="ml-4 inline-flex items-center gap-2">
|
<span className="ml-4 inline-flex items-center gap-2">
|
||||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
@@ -88,7 +98,12 @@ function EventoDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
<RosaPresenze
|
||||||
|
eventoId={evento.id}
|
||||||
|
data={evento.data}
|
||||||
|
tipo={evento.tipo}
|
||||||
|
convocati={evento.convocati}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Barra } from "@/components/motion/Barra";
|
|||||||
import { Numero } from "@/components/motion/Numero";
|
import { Numero } from "@/components/motion/Numero";
|
||||||
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
||||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||||
import { useEventi, type Evento } from "@/lib/eventi";
|
import { eConvocato, type Evento } from "@/lib/eventi";
|
||||||
|
import { useEventiVisibili } from "@/lib/eventi-visibili";
|
||||||
import { useRispostePresenze } from "@/lib/presenze";
|
import { useRispostePresenze } from "@/lib/presenze";
|
||||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||||
@@ -47,14 +48,14 @@ function Index() {
|
|||||||
const base = useGiocatoreBase();
|
const base = useGiocatoreBase();
|
||||||
const giocatore = useIo();
|
const giocatore = useIo();
|
||||||
const allenatore = isAllenatore(base);
|
const allenatore = isAllenatore(base);
|
||||||
const { eventi } = useEventi();
|
const { eventi } = useEventiVisibili();
|
||||||
const { presenze } = useRispostePresenze();
|
const { presenze } = useRispostePresenze();
|
||||||
const oggi = new Date().toISOString().slice(0, 10);
|
const oggi = new Date().toISOString().slice(0, 10);
|
||||||
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
const prossimi: Evento[] = eventi.filter((e) => e.data >= oggi).slice(0, 3);
|
||||||
const prossimo = prossimi[0] ?? null;
|
const prossimo = prossimi[0] ?? null;
|
||||||
const daConfermare = prossimi
|
const daConfermare = prossimi
|
||||||
.slice(1)
|
.slice(1)
|
||||||
.filter((e) => !giocatore || !presenze[e.id]?.[giocatore.id]);
|
.filter((e) => !giocatore || (eConvocato(e, giocatore.id) && !presenze[e.id]?.[giocatore.id]));
|
||||||
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
|
const linkProssimo = prossimo ? linkPerEvento(prossimo) : null;
|
||||||
const { data: csi } = useCsi();
|
const { data: csi } = useCsi();
|
||||||
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ function PartitaDetail() {
|
|||||||
<span className="ml-4 inline-flex items-center gap-2">
|
<span className="ml-4 inline-flex items-center gap-2">
|
||||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{evento.note ? (
|
{evento.note ? (
|
||||||
@@ -297,7 +307,12 @@ function PartitaDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
<RosaPresenze
|
||||||
|
eventoId={evento.id}
|
||||||
|
data={evento.data}
|
||||||
|
tipo={evento.tipo}
|
||||||
|
convocati={evento.convocati}
|
||||||
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- 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();
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- 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;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- 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*$'
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- 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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- 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;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- 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();
|
||||||
+6
-6
@@ -24,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push
|
|||||||
|
|
||||||
## Struttura
|
## Struttura
|
||||||
|
|
||||||
| Cartella | Cosa verifica | Serve rete? |
|
| Cartella | Cosa verifica | Serve rete? |
|
||||||
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi (calcolo e card di dettaglio), avvisi in Home, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup, logica del worker in `mailer/` (email e push dei promemoria), preferenza «Email» e avviso del sondaggio) | No |
|
||||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la coda delle email e le sue funzioni RPC (`notifiche-email`, M22), i promemoria evento che non ricompaiono se eliminati (`promemoria-eventi`, M23), la coda delle push dei promemoria (`push-promemoria`, M24), i job automatici di turno palloni e sollecito presenze con revoca, finestre e testi identici al codice (`notifiche-automatiche`, M26-M28 e M30, DD-040, DD-042, DD-043 e DD-045: palloni un solo avviso a 3 ore, sollecito uno solo a 24 ore, nessun promemoria evento) la colonna `foto_path` eliminata (`foto-tessera-rimossa`, M29, DD-044) e il sondaggio in-app/email (`sondaggio-notifiche`, M24), i destinatari delle notifiche per ruolo (`destinatari-notifiche`: promemoria, messaggio dello staff, sondaggio, sollecito e turno palloni con allenatore e giocatore), gli avvisi di compleanno con ora, più compleanni lo stesso giorno, registro e 29 febbraio (`notifiche-compleanno`, M31, DD-047), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | Sì |
|
| `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ì |
|
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||||
|
|
||||||
## Server di sviluppo con Node vecchio
|
## Server di sviluppo con Node vecchio
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { badgeDefs, statoBadge } from "@/lib/badges";
|
import { badgeDefs, statoBadge } from "@/lib/badges";
|
||||||
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
|
import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
|
||||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
@@ -52,11 +52,11 @@ if (!locale) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Rilegge esattamente come `useVotiMvp()`. */
|
/** Rilegge esattamente come `useVotiMvp()`. */
|
||||||
async function leggiVoti(): Promise<VotoMvp[]> {
|
async function leggiVoti(): Promise<VotoMvpPubblico[]> {
|
||||||
const res = await rest(
|
const res = await rest(
|
||||||
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
|
`mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
|
||||||
);
|
);
|
||||||
return (await res.json()) as VotoMvp[];
|
return (await res.json()) as VotoMvpPubblico[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
|
||||||
@@ -68,8 +68,8 @@ if (!locale) {
|
|||||||
await prova(
|
await prova(
|
||||||
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
|
"il badge MVP passa da bronzo ad argento con vittorie vere lette dal database",
|
||||||
async () => {
|
async () => {
|
||||||
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), pareggia in
|
// Il votato "vg1" vince nettamente le partite m1 e m2 (2 voti contro 1), in m3 ha un solo
|
||||||
// m3 (nessun vincitore) e perde in m4 (0 voti): dopo m1+m2 ha 2 vittorie -> bronzo,
|
// voto (sotto quorum: nessun vincitore) e in m4 nessuno: dopo m1+m2 ha 2 vittorie -> bronzo,
|
||||||
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
|
// non ancora argento (soglia 3). Il votato "vg2" non vince mai: resta senza badge.
|
||||||
await upsert({
|
await upsert({
|
||||||
match_id: `${PREFISSO}-m1`,
|
match_id: `${PREFISSO}-m1`,
|
||||||
@@ -107,17 +107,15 @@ if (!locale) {
|
|||||||
votato_id: "vg1",
|
votato_id: "vg1",
|
||||||
votato_nome: "Uno",
|
votato_nome: "Uno",
|
||||||
});
|
});
|
||||||
await upsert({
|
|
||||||
match_id: `${PREFISSO}-m3`,
|
|
||||||
votante_id: "vb",
|
|
||||||
votato_id: "vg2",
|
|
||||||
votato_nome: "Due",
|
|
||||||
});
|
|
||||||
|
|
||||||
const voti = await leggiVoti();
|
const voti = await leggiVoti();
|
||||||
const vinti = mvpVintiPerGiocatore(voti);
|
const vinti = mvpVintiPerGiocatore(voti);
|
||||||
assert.equal(vinti["vg1"], 2, "vg1 vince m1 e m2, pareggia in m3 (non conta)");
|
assert.equal(
|
||||||
assert.equal(vinti["vg2"] ?? 0, 0, "vg2 non vince mai nettamente");
|
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");
|
||||||
|
|
||||||
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
|
const badgeVg1 = statoBadge(mvpDef, giocatoreAzzerato(vinti["vg1"] ?? 0));
|
||||||
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
|
assert.equal(badgeVg1.grado, "bronzo", "2 vittorie: soglia bronzo (1) raggiunta");
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* Stato toccato e ripristinato alla fine: gli utenti creati, lo slot allenatore (cancellato),
|
* 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`.
|
* 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 assert from "node:assert/strict";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
@@ -259,8 +260,34 @@ if (!locale) {
|
|||||||
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
|
// Evento con `convocati` vuoto: tutta la rosa, ma l'allenatore resta fuori comunque.
|
||||||
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
await rest(`eventi_app?id=eq.${EVENTO}`, tokenAdmin, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ convocati: [] }),
|
// M36-M37: una partita recente, perché il voto sia aperto e il rifiuto dipenda dal ruolo.
|
||||||
|
body: JSON.stringify({ convocati: [], ...romaTra(-3) }),
|
||||||
});
|
});
|
||||||
|
// 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, {
|
const vota = await rest("mvp_voti", tokenAllenatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
|
import { giornoSenzaOraAperto, romaTra } from "../helpers/roma";
|
||||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||||
|
|
||||||
const locale = statoLocale();
|
const locale = statoLocale();
|
||||||
@@ -310,12 +311,47 @@ if (!locale) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`);
|
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, {
|
const mioMvp = await rest("mvp_voti", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
headers: { Prefer: "return=representation" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
match_id: EVENTO,
|
match_id: EVENTO_MVP,
|
||||||
votante_id: "g1",
|
votante_id: "g1",
|
||||||
votato_id: "g5",
|
votato_id: "g5",
|
||||||
votato_nome: "Cinque",
|
votato_nome: "Cinque",
|
||||||
@@ -323,11 +359,55 @@ if (!locale) {
|
|||||||
});
|
});
|
||||||
assert.equal(await righeToccate(mioMvp), 1, "il proprio voto MVP si registra");
|
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, {
|
const mvp = await rest("mvp_voti", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
headers: { Prefer: "return=representation" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
match_id: EVENTO,
|
match_id: EVENTO_MVP,
|
||||||
votante_id: "g5",
|
votante_id: "g5",
|
||||||
votato_id: "g1",
|
votato_id: "g1",
|
||||||
votato_nome: "Uno",
|
votato_nome: "Uno",
|
||||||
@@ -336,6 +416,265 @@ if (!locale) {
|
|||||||
assert.ok(!mvp.ok, `vale anche per l'MVP (${mvp.status})`);
|
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 () => {
|
await prova("le cacche le dichiara il diretto interessato", async () => {
|
||||||
const mie = await rest("cacche_partita", tokenGiocatore, {
|
const mie = await rest("cacche_partita", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -455,8 +794,8 @@ if (!locale) {
|
|||||||
id: EVENTO_CON_G1,
|
id: EVENTO_CON_G1,
|
||||||
tipo: "partita",
|
tipo: "partita",
|
||||||
titolo: "Partita con g1 convocato, g2 no",
|
titolo: "Partita con g1 convocato, g2 no",
|
||||||
data: "2026-01-02",
|
data: romaTra(-3).data,
|
||||||
ora: "20:00",
|
ora: romaTra(-3).ora,
|
||||||
luogo: "Palestra",
|
luogo: "Palestra",
|
||||||
convocati: ["g1", "g5"],
|
convocati: ["g1", "g5"],
|
||||||
}),
|
}),
|
||||||
@@ -482,9 +821,17 @@ if (!locale) {
|
|||||||
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
|
||||||
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
|
||||||
// (es. un evento inesistente) e sembrare comunque corretto.
|
// (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, {
|
const votoValido = await rest("mvp_voti", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
headers: { Prefer: "return=representation" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
match_id: EVENTO_CON_G1,
|
match_id: EVENTO_CON_G1,
|
||||||
votante_id: "g1",
|
votante_id: "g1",
|
||||||
@@ -503,8 +850,8 @@ if (!locale) {
|
|||||||
id: EVENTO_CHIUSO,
|
id: EVENTO_CHIUSO,
|
||||||
tipo: "partita",
|
tipo: "partita",
|
||||||
titolo: "Partita con pagelle chiuse",
|
titolo: "Partita con pagelle chiuse",
|
||||||
data: "2026-01-03",
|
data: romaTra(-3).data,
|
||||||
ora: "20:00",
|
ora: romaTra(-3).ora,
|
||||||
luogo: "Palestra",
|
luogo: "Palestra",
|
||||||
pagelle_chiuse: true,
|
pagelle_chiuse: true,
|
||||||
}),
|
}),
|
||||||
@@ -529,9 +876,17 @@ if (!locale) {
|
|||||||
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
|
// 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
|
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
|
||||||
// votabili sullo stesso evento.
|
// 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, {
|
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
headers: { Prefer: "return=representation" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
match_id: EVENTO_CHIUSO,
|
match_id: EVENTO_CHIUSO,
|
||||||
votante_id: "g1",
|
votante_id: "g1",
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
import { badgeSegreti, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
|
||||||
import { mvpVintiPerGiocatore, type VotoMvp } from "@/lib/mvp-voti";
|
import { mvpVintiPerGiocatore, type VotoMvp, type VotoMvpPubblico } from "@/lib/mvp-voti";
|
||||||
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
|
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
|
||||||
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
import { giocatori, type Giocatore } from "@/lib/crapp-data";
|
||||||
import { statoLocale } from "../helpers/locale";
|
import { statoLocale } from "../helpers/locale";
|
||||||
@@ -67,11 +67,11 @@ if (!locale) {
|
|||||||
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
|
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leggiMvp(): Promise<VotoMvp[]> {
|
async function leggiMvp(): Promise<VotoMvpPubblico[]> {
|
||||||
const res = await rest(
|
const res = await rest(
|
||||||
`mvp_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,votato_nome`,
|
`mvp_voti_anonimi?match_id=like.${PREFISSO}-*&select=match_id,votato_id,votato_nome`,
|
||||||
);
|
);
|
||||||
return (await res.json()) as VotoMvp[];
|
return (await res.json()) as VotoMvpPubblico[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leggiPagelle(): Promise<VotoPagella[]> {
|
async function leggiPagelle(): Promise<VotoPagella[]> {
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
convocatiEvento,
|
convocatiEvento,
|
||||||
daCategoria,
|
daCategoria,
|
||||||
daRiga,
|
daRiga,
|
||||||
|
eConvocato,
|
||||||
|
eventoVisibile,
|
||||||
|
rosaConvocata,
|
||||||
eventoVuoto,
|
eventoVuoto,
|
||||||
|
linkMapsValido,
|
||||||
type RigaEvento,
|
type RigaEvento,
|
||||||
} from "@/lib/eventi";
|
} from "@/lib/eventi";
|
||||||
|
|
||||||
@@ -32,6 +36,7 @@ assert.deepEqual(daRiga(riga), {
|
|||||||
tipo: "partita",
|
tipo: "partita",
|
||||||
titolo: "CRAP - Avversari",
|
titolo: "CRAP - Avversari",
|
||||||
luogo: "PalaCRAP",
|
luogo: "PalaCRAP",
|
||||||
|
linkMaps: "",
|
||||||
data: "2026-09-01",
|
data: "2026-09-01",
|
||||||
ora: "21:00",
|
ora: "21:00",
|
||||||
note: "portare la seconda maglia",
|
note: "portare la seconda maglia",
|
||||||
@@ -123,3 +128,78 @@ assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client");
|
|||||||
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
assert.notEqual(bozza.id, "", "ogni bozza ha un id");
|
||||||
|
|
||||||
console.log("eventi: ok");
|
console.log("eventi: ok");
|
||||||
|
|
||||||
|
// --- eventoVisibile: i non convocati non vedono l'evento, chi gestisce sì -----
|
||||||
|
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g1", false), true, "il convocato lo vede");
|
||||||
|
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", false), false, "il non convocato no");
|
||||||
|
assert.equal(eventoVisibile({ convocati: ["g1"] }, null, false), false, "senza slot no");
|
||||||
|
assert.equal(eventoVisibile({ convocati: ["g1"] }, "g2", true), true, "admin/allenatore sempre");
|
||||||
|
assert.equal(eventoVisibile({ convocati: [] }, "g2", false), true, "vuoto = tutta la rosa");
|
||||||
|
|
||||||
|
// --- eConvocato: elenco vuoto = tutti ----------------------------------------
|
||||||
|
assert.equal(eConvocato({ convocati: ["g1"] }, "g1"), true, "convocato");
|
||||||
|
assert.equal(eConvocato({ convocati: ["g1"] }, "g2"), false, "non convocato");
|
||||||
|
assert.equal(eConvocato({ convocati: [] }, "g2"), true, "vuoto = tutti");
|
||||||
|
|
||||||
|
// --- Copertura per numero di convocati: 1, 2, 3, tutti, nessuno, id sconosciuti -
|
||||||
|
const squadra = ["g1", "g2", "g3", "g4"].map((id) => ({ id }));
|
||||||
|
const ids = (l: Array<{ id: string }>) => l.map((g) => g.id);
|
||||||
|
const casi: Array<{ nome: string; convocati: string[]; attesi: string[] }> = [
|
||||||
|
{ nome: "nessuno indicato = tutta la rosa", convocati: [], attesi: ["g1", "g2", "g3", "g4"] },
|
||||||
|
{ nome: "solo 1", convocati: ["g2"], attesi: ["g2"] },
|
||||||
|
{ nome: "2", convocati: ["g1", "g4"], attesi: ["g1", "g4"] },
|
||||||
|
{ nome: "3", convocati: ["g3", "g1", "g2"], attesi: ["g1", "g2", "g3"] },
|
||||||
|
{ nome: "tutti indicati", convocati: ["g1", "g2", "g3", "g4"], attesi: ["g1", "g2", "g3", "g4"] },
|
||||||
|
{ nome: "id sconosciuto ignorato", convocati: ["g2", "zz"], attesi: ["g2"] },
|
||||||
|
{ nome: "solo ids sconosciuti: nessuno", convocati: ["zz"], attesi: [] },
|
||||||
|
];
|
||||||
|
for (const { nome, convocati, attesi } of casi) {
|
||||||
|
assert.deepEqual(ids(rosaConvocata(squadra, convocati)), attesi, `rosaConvocata: ${nome}`);
|
||||||
|
const conv = { convocati };
|
||||||
|
for (const { id } of squadra) {
|
||||||
|
const dentro = attesi.includes(id) || convocati.length === 0;
|
||||||
|
assert.equal(
|
||||||
|
eConvocato(conv, id),
|
||||||
|
convocati.length === 0 || convocati.includes(id),
|
||||||
|
`eConvocato ${id}: ${nome}`,
|
||||||
|
);
|
||||||
|
assert.equal(eventoVisibile(conv, id, false), dentro, `visibile a ${id}: ${nome}`);
|
||||||
|
assert.equal(
|
||||||
|
eventoVisibile(conv, id, true),
|
||||||
|
true,
|
||||||
|
`admin/allenatore vede sempre (${id}): ${nome}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(eventoVisibile(conv, null, false), convocati.length === 0, `senza slot: ${nome}`);
|
||||||
|
assert.equal(eventoVisibile(conv, null, true), true, `admin senza slot: ${nome}`);
|
||||||
|
}
|
||||||
|
assert.equal(rosaConvocata([], ["g1"]).length, 0, "rosa vuota resta vuota");
|
||||||
|
|
||||||
|
// Link Google Maps: solo URL https di Google Maps, e vuoto se la riga non ne ha uno.
|
||||||
|
assert.equal(daRiga(riga).linkMaps, "");
|
||||||
|
assert.equal(daRiga({ ...riga, link_maps: null }).linkMaps, "");
|
||||||
|
assert.equal(
|
||||||
|
daRiga({ ...riga, link_maps: "https://maps.app.goo.gl/abc123" }).linkMaps,
|
||||||
|
"https://maps.app.goo.gl/abc123",
|
||||||
|
);
|
||||||
|
assert.equal(eventoVuoto().linkMaps, "");
|
||||||
|
for (const ok of [
|
||||||
|
"https://maps.app.goo.gl/abc123",
|
||||||
|
"https://goo.gl/maps/xyz",
|
||||||
|
"https://www.google.com/maps/place/PalaCRAP",
|
||||||
|
"https://google.it/maps?q=Cesena",
|
||||||
|
"https://maps.google.com/?q=Cesena",
|
||||||
|
]) {
|
||||||
|
assert.equal(linkMapsValido(ok), true, ok);
|
||||||
|
}
|
||||||
|
for (const ko of [
|
||||||
|
"",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"http://maps.app.goo.gl/abc123",
|
||||||
|
"https://example.com/maps",
|
||||||
|
"https://maps.app.goo.gl.evil.com/abc",
|
||||||
|
"https://www.google.com/search?q=maps",
|
||||||
|
"https://maps.app.goo.gl/a b",
|
||||||
|
]) {
|
||||||
|
assert.equal(linkMapsValido(ko), false, ko);
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
|
/** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import {
|
import {
|
||||||
conteggioPartita,
|
conteggioPartita,
|
||||||
|
elencoNomi,
|
||||||
mioVoto,
|
mioVoto,
|
||||||
|
mvpPartita,
|
||||||
mvpVintiPerGiocatore,
|
mvpVintiPerGiocatore,
|
||||||
|
DURATA_VOTO_MVP_ORE,
|
||||||
|
ORE_ATTESA_MVP,
|
||||||
vincitoriMvp,
|
vincitoriMvp,
|
||||||
votoMvpAperto,
|
votoMvpAperto,
|
||||||
|
votoMvpChiuso,
|
||||||
|
votoMvpInCorso,
|
||||||
VOTI_MINIMI_MVP,
|
VOTI_MINIMI_MVP,
|
||||||
type VotoMvp,
|
type VotoMvp,
|
||||||
} from "@/lib/mvp-voti";
|
} from "@/lib/mvp-voti";
|
||||||
@@ -44,9 +51,13 @@ assert.deepEqual(
|
|||||||
["Anna", "Zeno"],
|
["Anna", "Zeno"],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- vincitoriMvp: la parità non assegna nessun MVP --------------------------
|
// --- vincitoriMvp: la parità in testa assegna l'MVP a tutti i pari merito --------------------------
|
||||||
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
|
assert.deepEqual(vincitoriMvp(partita), { m1: "Bruno", m2: "Anna" });
|
||||||
assert.deepEqual(vincitoriMvp(pari), {}, "due voti pari: MVP non assegnato");
|
assert.deepEqual(
|
||||||
|
vincitoriMvp(pari),
|
||||||
|
{ m3: "Anna e Zeno" },
|
||||||
|
"due voti pari in testa: sono MVP entrambi, a pari merito",
|
||||||
|
);
|
||||||
assert.deepEqual(vincitoriMvp([]), {});
|
assert.deepEqual(vincitoriMvp([]), {});
|
||||||
|
|
||||||
// --- vincitoriMvp: quorum minimo di voti -------------------------------------
|
// --- vincitoriMvp: quorum minimo di voti -------------------------------------
|
||||||
@@ -69,13 +80,38 @@ const triplaPari = [
|
|||||||
v("m6", "g2", "g8", "Anna"),
|
v("m6", "g2", "g8", "Anna"),
|
||||||
v("m6", "g3", "g7", "Bea"),
|
v("m6", "g3", "g7", "Bea"),
|
||||||
];
|
];
|
||||||
assert.deepEqual(vincitoriMvp(triplaPari), {}, "primo e secondo pari: nessun MVP anche a 3 vie");
|
assert.deepEqual(
|
||||||
|
vincitoriMvp(triplaPari),
|
||||||
|
{ m6: "Anna, Bea e Zeno" },
|
||||||
|
"tre pari in testa: tre MVP, in ordine alfabetico",
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mvpVintiPerGiocatore(triplaPari),
|
mvpVintiPerGiocatore(triplaPari),
|
||||||
{},
|
{ g9: 1, g8: 1, g7: 1 },
|
||||||
"stessa parità: nessuna vittoria netta da contare",
|
"ognuno dei pari merito conta un titolo",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Primo pari col secondo, terzo staccato: il terzo resta fuori.
|
||||||
|
const duePariUnoSotto = [
|
||||||
|
v("m7", "g1", "g9", "Zeno"),
|
||||||
|
v("m7", "g2", "g9", "Zeno"),
|
||||||
|
v("m7", "g3", "g8", "Anna"),
|
||||||
|
v("m7", "g4", "g8", "Anna"),
|
||||||
|
v("m7", "g5", "g7", "Bea"),
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
mvpPartita(duePariUnoSotto, "m7").map((c) => c.nome),
|
||||||
|
["Anna", "Zeno"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(mvpPartita([v("m8", "g1", "g2", "Solo")], "m8"), [], "sotto quorum: nessuno");
|
||||||
|
assert.deepEqual(mvpPartita([], "m8"), []);
|
||||||
|
|
||||||
|
// --- elencoNomi ---
|
||||||
|
assert.equal(elencoNomi([]), "");
|
||||||
|
assert.equal(elencoNomi(["Anna"]), "Anna");
|
||||||
|
assert.equal(elencoNomi(["Anna", "Bruno"]), "Anna e Bruno");
|
||||||
|
assert.equal(elencoNomi(["Anna", "Bruno", "Carla"]), "Anna, Bruno e Carla");
|
||||||
|
|
||||||
// --- mioVoto -----------------------------------------------------------------
|
// --- mioVoto -----------------------------------------------------------------
|
||||||
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
|
assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno");
|
||||||
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
|
assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto");
|
||||||
@@ -89,8 +125,8 @@ assert.deepEqual(
|
|||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mvpVintiPerGiocatore([...partita, ...pari]),
|
mvpVintiPerGiocatore([...partita, ...pari]),
|
||||||
{ g2: 1, g5: 1 },
|
{ g2: 1, g5: 1, g9: 1, g8: 1 },
|
||||||
"la parità non assegna",
|
"m3 è pari: i due pari merito prendono un titolo ciascuno",
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
|
mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno"), v("m5", "g3", "g2", "Bruno")]),
|
||||||
@@ -114,4 +150,32 @@ assert.equal(votoMvpAperto("2026-09-04", "20:30", alle(0, 1)), true, "partita pa
|
|||||||
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
|
assert.equal(votoMvpAperto("2026-09-05", "", alle(1, 59)), false, "senza ora vale mezzanotte");
|
||||||
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
|
assert.equal(votoMvpAperto("2026-09-05", "", alle(2)), true);
|
||||||
|
|
||||||
|
// --- votoMvpChiuso: 24 ore dopo l'apertura (2 ore dopo l'inizio) -----------------
|
||||||
|
assert.equal(DURATA_VOTO_MVP_ORE, 24);
|
||||||
|
const giorno = (g: number, o: number, m = 0) => new Date(2026, 8, g, o, m);
|
||||||
|
// partita del 5/9 alle 20:30: apre il 5 alle 22:30, chiude il 6 alle 22:30
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 29)), false, "un minuto prima");
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(6, 22, 30)), true, "24 ore in punto");
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(20, 12)), true, "partita vecchia");
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "20:30", giorno(5, 12)), false, "prima della partita");
|
||||||
|
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 21)), false, "non ancora aperto");
|
||||||
|
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(5, 23)), true, "aperto");
|
||||||
|
assert.equal(votoMvpInCorso("2026-09-05", "20:30", giorno(7, 8)), false, "chiuso");
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 1, 59)), false, "senza ora vale mezzanotte");
|
||||||
|
assert.equal(votoMvpChiuso("2026-09-05", "", giorno(6, 2)), true);
|
||||||
|
|
||||||
|
// --- l'app e il database (M36, M37) hanno la stessa finestra -----------------------
|
||||||
|
const m37 = readFileSync(
|
||||||
|
"supabase/migrations/20261026170000_m37_voto_mvp_segreto_e_chiusura.sql",
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
m37.includes(`interval '${ORE_ATTESA_MVP} hours'`),
|
||||||
|
"ORE_ATTESA_MVP e l'attesa di voto_mvp_consentito() devono coincidere",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
m37.includes(`interval '${DURATA_VOTO_MVP_ORE} hours'`),
|
||||||
|
"DURATA_VOTO_MVP_ORE e la chiusura di voto_mvp_consentito() devono coincidere",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("mvp-voti: ok");
|
console.log("mvp-voti: ok");
|
||||||
@@ -61,6 +61,7 @@ const tipiNuovi: TipoNotifica[] = [
|
|||||||
"sollecita_presenze_6h",
|
"sollecita_presenze_6h",
|
||||||
"compleanno",
|
"compleanno",
|
||||||
"compleanno_auguri",
|
"compleanno_auguri",
|
||||||
|
"presenza_modificata",
|
||||||
];
|
];
|
||||||
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
const corpoMultiriga = "Data: 01/10/2026\nOra: 21:00\nLuogo: PalaCRAP";
|
||||||
for (const tipo of tipiNuovi) {
|
for (const tipo of tipiNuovi) {
|
||||||
|
|||||||
@@ -294,3 +294,35 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log("presenze: ok");
|
console.log("presenze: ok");
|
||||||
|
|
||||||
|
// --- Solleciti per numero di convocati: 1, 2, 3, tutti ------------------------
|
||||||
|
{
|
||||||
|
const sq = (["g1", "g2", "g3", "g4"] as const).map((id) => ({
|
||||||
|
id,
|
||||||
|
attivo: true,
|
||||||
|
tipo: "giocatore" as const,
|
||||||
|
}));
|
||||||
|
const nessuna: Array<{ giocatore_id: string; stato: string }> = [];
|
||||||
|
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g2"]), ["g2"], "solo 1 convocato");
|
||||||
|
assert.deepEqual(destinatariSollecito(sq, nessuna, ["g1", "g3"]), ["g1", "g3"], "2 convocati");
|
||||||
|
assert.deepEqual(
|
||||||
|
destinatariSollecito(sq, nessuna, ["g1", "g2", "g4"]),
|
||||||
|
["g1", "g2", "g4"],
|
||||||
|
"3 convocati",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
destinatariSollecito(sq, [{ giocatore_id: "g1", stato: "presente" }], ["g1", "g3"]),
|
||||||
|
["g3"],
|
||||||
|
"il convocato che ha già risposto non va sollecitato",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
destinatariSollecito(sq, [{ giocatore_id: "g4", stato: "presente" }], ["g1"]),
|
||||||
|
["g1"],
|
||||||
|
"chi ha risposto ma non è convocato non cambia i destinatari",
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
destinatariSollecito(sq, nessuna, []),
|
||||||
|
["g1", "g2", "g3", "g4"],
|
||||||
|
"nessuno = tutti",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user