Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 |
No files matched your search
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
Ultimo aggiornamento: 24/09/2026
|
Ultimo aggiornamento: 27/09/2026
|
||||||
|
|
||||||
## Stato generale
|
## Stato generale
|
||||||
|
|
||||||
@@ -178,9 +178,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
|||||||
|
|
||||||
## Prossimo sviluppo
|
## Prossimo sviluppo
|
||||||
|
|
||||||
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
|
Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione
|
||||||
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
|
1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||||
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+33
-12
@@ -35,7 +35,7 @@ Da fare una volta sola per host (poi il backup si rilancia semplicemente con
|
|||||||
Project Settings → API. La **service role key** è quella con permessi pieni
|
Project Settings → API. La **service role key** è quella con permessi pieni
|
||||||
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
|
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
|
||||||
esposta lato client né committata.
|
esposta lato client né committata.
|
||||||
5. **(Solo se userai `--encrypt`) Verifica che gpg sia installato**:
|
5. **Verifica che gpg sia installato** (serve per cifrare, di default):
|
||||||
```bash
|
```bash
|
||||||
gpg --version
|
gpg --version
|
||||||
```
|
```
|
||||||
@@ -46,15 +46,21 @@ A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
|
|||||||
## Uso
|
## Uso
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/ con tutto dentro
|
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
|
||||||
./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico
|
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||||
# backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
# passphrase a te scelta) e cancella cartella+tar.gz
|
||||||
# passphrase) e cancella la cartella in chiaro
|
# in chiaro
|
||||||
|
./backups/backup.sh --no-encrypt # come sopra ma lascia solo il tar.gz in chiaro
|
||||||
|
# (nessuna passphrase richiesta)
|
||||||
```
|
```
|
||||||
|
|
||||||
Ogni esecuzione crea una cartella nuova con timestamp: non sovrascrive backup
|
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
|
||||||
precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano
|
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
|
||||||
quando non servono più.
|
se la perdi, il backup cifrato è irrecuperabile. Tienila in un password manager.
|
||||||
|
|
||||||
|
Ogni esecuzione crea una cartella/archivio nuovo con timestamp: non sovrascrive
|
||||||
|
backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a
|
||||||
|
mano quando non servono più.
|
||||||
|
|
||||||
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
|
Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato),
|
||||||
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
|
lo script si ferma con un errore e **non** stampa «Backup completato»: un backup
|
||||||
@@ -70,6 +76,23 @@ Il backup contiene:
|
|||||||
| `data_auth.sql` | utenti reali, incluso l'hash della password |
|
| `data_auth.sql` | utenti reali, incluso l'hash della password |
|
||||||
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
|
| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) |
|
||||||
|
|
||||||
|
## Decomprimere
|
||||||
|
|
||||||
|
Archivio cifrato (caso di default), chiede la passphrase scelta al momento
|
||||||
|
del backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz -C backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
Ricrea `backups/AAAAMMGG_hhmm/` con dentro i file elencati sopra.
|
||||||
|
|
||||||
|
Archivio non cifrato (generato con `--no-encrypt`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar xzf backups/AAAAMMGG_hhmm.tar.gz -C backups/
|
||||||
|
```
|
||||||
|
|
||||||
## Ripristino
|
## Ripristino
|
||||||
|
|
||||||
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
|
1. Nuovo progetto Supabase (o `supabase start` in locale via Docker).
|
||||||
@@ -83,8 +106,6 @@ Il backup contiene:
|
|||||||
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
|
5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su
|
||||||
GitHub e Gitea).
|
GitHub e Gitea).
|
||||||
|
|
||||||
Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`.
|
|
||||||
|
|
||||||
## Cosa NON è incluso
|
## Cosa NON è incluso
|
||||||
|
|
||||||
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
|
- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non
|
||||||
@@ -98,8 +119,8 @@ personali reali. Per questo:
|
|||||||
|
|
||||||
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
|
- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script:
|
||||||
quelli sì restano in git) — **non committare mai i dati generati**;
|
quelli sì restano in git) — **non committare mai i dati generati**;
|
||||||
- se sposti un backup fuori da questo host, usa sempre `--encrypt` o cifra tu
|
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
|
||||||
l'archivio a mano;
|
invece del tar.gz in chiaro generato con `--no-encrypt`;
|
||||||
- cancella i backup in chiaro quando non ti servono più.
|
- cancella i backup in chiaro quando non ti servono più.
|
||||||
|
|
||||||
## File di questa cartella
|
## File di questa cartella
|
||||||
|
|||||||
+16
-9
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
||||||
# Uso:
|
# Uso:
|
||||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto
|
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, comprime e cifra con gpg (chiede una passphrase)
|
||||||
# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase)
|
# ./backups/backup.sh --no-encrypt come sopra ma lascia solo il tar.gz in chiaro
|
||||||
#
|
#
|
||||||
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
|
# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto
|
||||||
# in questo repo) e avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY.
|
# in questo repo), avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY, e bun
|
||||||
|
# installato (esegue download-storage.mjs, che importa un modulo .ts).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -29,15 +30,21 @@ echo "==> Dump dati auth (utenti)"
|
|||||||
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
|
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
|
||||||
|
|
||||||
echo "==> Download file storage (bucket)"
|
echo "==> Download file storage (bucket)"
|
||||||
node "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
|
bun "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage"
|
||||||
|
|
||||||
echo "==> Backup completato: $RUN_DIR"
|
echo "==> Backup completato: $RUN_DIR"
|
||||||
|
|
||||||
if [[ "${1:-}" == "--encrypt" ]]; then
|
TARBALL="$RUN_DIR.tar.gz"
|
||||||
ARCHIVE="$RUN_DIR.tar.gz.gpg"
|
echo "==> Comprimo in $TARBALL"
|
||||||
echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
|
||||||
tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE"
|
|
||||||
rm -rf "$RUN_DIR"
|
if [[ "${1:-}" == "--no-encrypt" ]]; then
|
||||||
|
echo "==> Archivio pronto: $TARBALL"
|
||||||
|
else
|
||||||
|
ARCHIVE="$TARBALL.gpg"
|
||||||
|
echo "==> Cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
||||||
|
gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" "$TARBALL"
|
||||||
|
rm -rf "$RUN_DIR" "$TARBALL"
|
||||||
echo "==> Archivio cifrato pronto: $ARCHIVE"
|
echo "==> Archivio cifrato pronto: $ARCHIVE"
|
||||||
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
|
echo " Per estrarlo: gpg -d $ARCHIVE | tar xz"
|
||||||
fi
|
fi
|
||||||
@@ -13,6 +13,47 @@ versioni `0.y.z` erano pre-release.
|
|||||||
|
|
||||||
## [Non rilasciato]
|
## [Non rilasciato]
|
||||||
|
|
||||||
|
## [1.1.1] - 2026-09-28
|
||||||
|
|
||||||
|
### Aggiunto
|
||||||
|
|
||||||
|
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta
|
||||||
|
al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
|
||||||
|
pausa dopo 7 giorni senza richieste API.
|
||||||
|
- **Scheda evento** — gli eventi extra-campo (es. cena di squadra) hanno ora una pagina di
|
||||||
|
dettaglio propria, come allenamenti e partite, con l'elenco nominativo di chi ha risposto
|
||||||
|
presente/assente/forse invece del solo conteggio in card. "Infortunato" non compare più
|
||||||
|
tra le opzioni né nei badge/riepiloghi per questi eventi, dato che non è una risposta
|
||||||
|
pertinente.
|
||||||
|
|
||||||
|
### Modificato
|
||||||
|
|
||||||
|
- **Backup completo** — la cifratura (gpg, `tar.gz.gpg`) è ora il comportamento di default
|
||||||
|
invece che opt-in con `--encrypt`, dato che i dump contengono dati sensibili (hash
|
||||||
|
password, documenti d'identità); si disattiva con `--no-encrypt`. Aggiunte le istruzioni
|
||||||
|
di decompressione nel README.
|
||||||
|
|
||||||
|
### Corretto
|
||||||
|
|
||||||
|
- **Squadra** — la card dell'allenatore nella Rosa è collassata come quella dei giocatori
|
||||||
|
(nome e ruolo in riga chiusa, data di nascita solo aprendo il dettaglio) invece di
|
||||||
|
mostrare subito tutto.
|
||||||
|
- **Squadra** — uno scroll verticale veloce nella tab Stats non fa più scattare per errore
|
||||||
|
il cambio tab verso Obiettivi.
|
||||||
|
- **Turno palloni** — gli eventi generici (es. cena di squadra) non entrano più nel turno
|
||||||
|
palloni: niente più proposta automatica, promemoria push o conteggio per eventi che non
|
||||||
|
sono allenamenti o partite.
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-09-27
|
||||||
|
|
||||||
|
### Aggiunto
|
||||||
|
|
||||||
|
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||||
|
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
|
||||||
|
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
|
||||||
|
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
|
||||||
|
nessuna push (DD-035).
|
||||||
|
|
||||||
## [1.0.0] - 2026-09-24
|
## [1.0.0] - 2026-09-24
|
||||||
|
|
||||||
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ Serve a rispondere a domande del tipo:
|
|||||||
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
|
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
|
||||||
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
|
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
|
||||||
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
|
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
|
||||||
|
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
|
||||||
|
|
||||||
**In valutazione**
|
**In valutazione**
|
||||||
|
|
||||||
@@ -1451,3 +1452,61 @@ collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando
|
|||||||
**Riesame**
|
**Riesame**
|
||||||
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
|
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
|
||||||
fonte di bug, passare alla tabella `staff` separata.
|
fonte di bug, passare alla tabella `staff` separata.
|
||||||
|
|
||||||
|
### DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
||||||
|
|
||||||
|
**Data:** 24 settembre 2026
|
||||||
|
**Stato:** Accettata (implementata, versione 1.1.0)
|
||||||
|
|
||||||
|
**Contesto**
|
||||||
|
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
||||||
|
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
|
||||||
|
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
|
||||||
|
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
|
||||||
|
certificato viene rinnovato. Specifica completa in
|
||||||
|
[modules/profilo-giocatore.md](modules/profilo-giocatore.md#avviso-certificati).
|
||||||
|
|
||||||
|
**Decisione**
|
||||||
|
|
||||||
|
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
|
||||||
|
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
|
||||||
|
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
|
||||||
|
riferito solo a sé. L'allenatore e i compagni non lo vedono.
|
||||||
|
3. L'avviso dello staff **non è cliccabile**; quello personale apre
|
||||||
|
`/profilo?tab=documenti`.
|
||||||
|
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
|
||||||
|
ancora (come `statoScadenza()`).
|
||||||
|
5. **Niente push**: l'avviso vive solo in Home.
|
||||||
|
|
||||||
|
**Alternative scartate**
|
||||||
|
|
||||||
|
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
|
||||||
|
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
|
||||||
|
che all'allenatore non serve ad agire.
|
||||||
|
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
|
||||||
|
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
|
||||||
|
è gratis e sempre giusto.
|
||||||
|
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
|
||||||
|
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
|
||||||
|
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
|
||||||
|
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
|
||||||
|
insieme ed è proprio lì che l'elenco completo serve.
|
||||||
|
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
|
||||||
|
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
||||||
|
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
|
||||||
|
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
||||||
|
dal widget «Completa il tuo profilo» e dalla tab Profili.
|
||||||
|
|
||||||
|
**Conseguenze**
|
||||||
|
|
||||||
|
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
|
||||||
|
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
|
||||||
|
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
|
||||||
|
da solo.
|
||||||
|
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
|
||||||
|
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
|
||||||
|
|
||||||
|
**Riesame**
|
||||||
|
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
|
||||||
|
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
|
||||||
|
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
|
||||||
@@ -38,6 +38,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
|||||||
calcolata dal CSI
|
calcolata dal CSI
|
||||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||||
|
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||||
|
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||||
|
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
||||||
|
|
||||||
## Prossimo
|
## Prossimo
|
||||||
|
|
||||||
|
|||||||
+16
-14
@@ -69,7 +69,8 @@ quando quei campi sono compilati.
|
|||||||
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
||||||
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
||||||
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
||||||
«Colpo d'occhio» con le statistiche personali.
|
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
|
||||||
|
vedono solo gli admin e il giocatore interessato (DD-035).
|
||||||
|
|
||||||
## Squadra
|
## Squadra
|
||||||
|
|
||||||
@@ -148,19 +149,20 @@ giocatore).
|
|||||||
|
|
||||||
## Permessi (riepilogo)
|
## Permessi (riepilogo)
|
||||||
|
|
||||||
| Azione | Giocatore | Allenatore | Admin |
|
| Azione | Giocatore | Allenatore | Admin |
|
||||||
| ----------------------------------------------- | --------- | ------------------ | ------------------ |
|
| ----------------------------------------------- | ---------- | ------------------ | ------------------ |
|
||||||
| Modificare nome e cognome | no | sì, i propri | tutti |
|
| Modificare nome e cognome | no | sì, i propri | tutti |
|
||||||
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
||||||
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
||||||
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
| Avviso certificati in Home | il proprio | no | tutta la rosa |
|
||||||
| Sollecitare le presenze | no | sì | sì |
|
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
||||||
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
| Sollecitare le presenze | no | sì | sì |
|
||||||
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
||||||
| Tab Stagione e Badge del profilo | sì | no | sì |
|
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
||||||
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
| Tab Stagione e Badge del profilo | sì | no | sì |
|
||||||
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
||||||
| Dashboard `/admin` | no | no | sì |
|
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
||||||
|
| Dashboard `/admin` | no | no | sì |
|
||||||
|
|
||||||
## Implementazione
|
## Implementazione
|
||||||
|
|
||||||
|
|||||||
@@ -408,11 +408,12 @@ meccanismo:
|
|||||||
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
`scritture.test.ts`). Non sfruttabile da un utente normale (l'app manda solo le 5 categorie
|
||||||
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
valide), stesso tipo di gap "solo applicativo, non a DB" del punto sotto sul votato/convocato.
|
||||||
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
- **`conteggioTurni()` non filtra per tipo evento** (`palloni-core.ts:70-82`), a differenza di
|
||||||
`eventiPalloni()` che scarta i compleanni. Un turno registrato per errore su un evento fuori
|
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
|
||||||
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
|
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||||
teorico basso (l'UI non offre questa combinazione), comportamento pinnato da un test dedicato
|
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
|
||||||
in `palloni-core.test.ts` così che un domani, se serve stringere, non lo si scopra rompendo un
|
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
|
||||||
test esistente ma leggendo perché quel test lo dimostrava apposta.
|
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
|
||||||
|
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||||
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
|
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
|
||||||
(card condivisa)
|
(card condivisa), `src/routes/allenamento.$id.tsx`, `src/routes/partita.$id.tsx`,
|
||||||
|
`src/routes/evento.$id.tsx` (schede di dettaglio, una per tipo)
|
||||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -44,8 +45,15 @@ Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 mi
|
|||||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||||
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
`linkPerEvento()` decide dove porta il click — `/partita/$id` per una partita (con
|
||||||
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
`/partita-csi/$id` come alternativa "solo CSI" quando non c'è un evento collegato, vedi
|
||||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
|
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
|
||||||
eventi virtuali (compleanni).
|
extra-campo, nessun link per gli eventi virtuali (compleanni), che non hanno una riga in
|
||||||
|
`eventi_app`.
|
||||||
|
|
||||||
|
Le tre schede di dettaglio (`allenamento.$id.tsx`, `partita.$id.tsx`, `evento.$id.tsx`)
|
||||||
|
condividono la stessa struttura — intestazione, note, conferme, `RosaPresenze` per l'elenco
|
||||||
|
nominativo di chi ha risposto — con `TurnoPalloni` in più per allenamenti e partite (vedi
|
||||||
|
`palloni.md`, non pertinente per gli eventi extra-campo) e lo stato `infortunato` escluso
|
||||||
|
dalle risposte solo sulla scheda evento (vedi `presenze.md`).
|
||||||
|
|
||||||
## Lettura lato server
|
## Lettura lato server
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ indipendenti:
|
|||||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||||
|
|
||||||
|
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||||
|
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||||
|
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||||
|
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dati
|
## Dati
|
||||||
|
|||||||
+16
-10
@@ -11,7 +11,8 @@
|
|||||||
|
|
||||||
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
Gestire un turno a rotazione condiviso per chi porta e riporta i palloni ad allenamenti e
|
||||||
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
partite, con proposta automatica, possibilità di modifica manuale e promemoria push il
|
||||||
giorno stesso.
|
giorno stesso. Gli eventi extra-campo (`tipo === "evento"`, es. cena di squadra) e i
|
||||||
|
compleanni non c'entrano: non richiedono palloni.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,11 +26,14 @@ compaiono.
|
|||||||
|
|
||||||
## Implementazione
|
## Implementazione
|
||||||
|
|
||||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
|
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
|
||||||
turno già salvato, il candidato con meno turni fatti, poi quello che non lo fa da più
|
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||||
tempo, poi per ordine alfabetico — un algoritmo greedy, non un ordine fisso né solo per
|
scarta eventi extra-campo e compleanni.
|
||||||
data. Gli **allenamenti** non ricevono proposta automatica: restano «da assegnare» finché
|
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
|
||||||
qualcuno non sceglie un incaricato in `TurnoPalloni` (scelta della squadra).
|
il candidato con meno turni fatti, poi quello che non lo fa da più tempo, poi per ordine
|
||||||
|
alfabetico — un algoritmo greedy, non un ordine fisso né solo per data. Gli **allenamenti**
|
||||||
|
non ricevono proposta automatica: restano «da assegnare» finché qualcuno non sceglie un
|
||||||
|
incaricato in `TurnoPalloni` (scelta della squadra).
|
||||||
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
- `useAssegnaTurno()` (`palloni.ts`) conferma una proposta o riassegna manualmente, con
|
||||||
upsert su `evento_id`.
|
upsert su `evento_id`.
|
||||||
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
- Il conteggio "quante volte hai portato i palloni" mostrato nel profilo e nei badge è
|
||||||
@@ -71,10 +75,12 @@ nessuna chiamata di rete. Stesso meccanismo di `apri-sondaggio` (vedi
|
|||||||
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
- La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto
|
||||||
"assente" o "infortunato" per quell'evento.
|
"assente" o "infortunato" per quell'evento.
|
||||||
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
- **`conteggioTurni()` non filtra per tipo evento** (a differenza di `eventiPalloni()`, che
|
||||||
scarta i compleanni): guarda solo `e.data < oggi`. Un turno registrato per errore su un
|
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
|
||||||
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
|
||||||
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
|
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
|
||||||
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
|
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
|
||||||
|
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
|
||||||
|
dedicato in `palloni-core.test.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ attuale.
|
|||||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||||
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
`ritardo`, `infortunato`. Solo `presente` e `ritardo` contano come presenza effettiva nelle
|
||||||
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
statistiche. L'assenza di una riga per `(evento, giocatore)` equivale a "non ha ancora
|
||||||
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
|
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
|
||||||
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
|
extra-campo (`tipo === "evento"`, es. cena di squadra) non è pertinente e resta escluso
|
||||||
|
ovunque — badge di riepilogo, gruppi elenco e bottoni di risposta — sia nella card
|
||||||
|
(`EventoCard`, tramite `includeInfortunato()` in `presenze.ts`) sia nella scheda di dettaglio
|
||||||
|
(`RosaPresenze`, prop `tipo`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,8 +44,9 @@ risposte_presenze (Supabase)
|
|||||||
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
useRispostePresenze() → src/lib/presenze.ts (1 query per sessione, staleTime 5 min,
|
||||||
↓ legge tutta la tabella)
|
↓ legge tutta la tabella)
|
||||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
|
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
|
||||||
allenamento.$id.tsx / partita.$id.tsx
|
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
|
||||||
|
partita.$id.tsx / evento.$id.tsx
|
||||||
|
|
||||||
EventoCard → src/components/crapp/EventoCard.tsx
|
EventoCard → src/components/crapp/EventoCard.tsx
|
||||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
|
|||||||
|
|
||||||
## Home
|
## Home
|
||||||
|
|
||||||
Il giocatore visualizza un widget dedicato.
|
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||||
|
|
||||||
### Completa il tuo profilo
|
### Completa il tuo profilo
|
||||||
|
|
||||||
@@ -80,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
|||||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||||
sulla tab Stagione.
|
sulla tab Stagione.
|
||||||
|
|
||||||
|
### Avviso certificati
|
||||||
|
|
||||||
|
**Stato:** implementato (DD-035).
|
||||||
|
|
||||||
|
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||||
|
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||||
|
già salvata: niente tabella, niente migration, niente push.
|
||||||
|
|
||||||
|
#### Chi lo vede
|
||||||
|
|
||||||
|
| Utente | Cosa vede | Al tocco |
|
||||||
|
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
|
||||||
|
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||||
|
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||||
|
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
|
||||||
|
| Altri giocatori | niente | — |
|
||||||
|
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||||
|
|
||||||
|
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||||
|
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||||
|
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||||
|
nuovo, come il widget «Completa il tuo profilo».
|
||||||
|
|
||||||
|
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||||
|
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||||
|
solo proprio a un giocatore.
|
||||||
|
|
||||||
|
#### Quando compare
|
||||||
|
|
||||||
|
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
|
||||||
|
`GIORNI_AVVISO_CERTIFICATO = 7`.
|
||||||
|
|
||||||
|
| Condizione | Stato | Avviso |
|
||||||
|
| -------------------- | ----------- | ------------------ |
|
||||||
|
| `giorni > 7` | valido | nessuno |
|
||||||
|
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
|
||||||
|
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||||
|
| data o file mancanti | mancante | nessuno |
|
||||||
|
|
||||||
|
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||||
|
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||||
|
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||||
|
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
||||||
|
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
||||||
|
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||||
|
cioè `attivo` e `tipo = 'giocatore'`).
|
||||||
|
|
||||||
|
#### Testi
|
||||||
|
|
||||||
|
| Avviso | Titolo (staff / personale) | Riga |
|
||||||
|
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||||
|
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||||
|
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||||
|
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||||
|
|
||||||
|
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
|
||||||
|
conserva niente, rilegge la data a ogni apertura della Home.
|
||||||
|
|
||||||
|
#### Più giocatori
|
||||||
|
|
||||||
|
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||||
|
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||||
|
scadono molti insieme.
|
||||||
|
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||||
|
nero, poi quello giallo.
|
||||||
|
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||||
|
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||||
|
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||||
|
|
||||||
|
#### Aspetto e posizione
|
||||||
|
|
||||||
|
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||||
|
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||||
|
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||||
|
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||||
|
di confondersi con lo sfondo.
|
||||||
|
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||||
|
ciò che scade oggi, poi ciò che manca.
|
||||||
|
|
||||||
|
#### Rinnovo
|
||||||
|
|
||||||
|
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||||
|
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||||
|
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||||
|
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||||
|
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||||
|
|
||||||
|
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||||
|
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||||
|
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||||
|
data, chiunque l'abbia scritta.
|
||||||
|
|
||||||
|
#### Caricamento
|
||||||
|
|
||||||
|
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||||
|
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||||
|
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||||
|
la Home non deve rompersi per un dato accessorio.
|
||||||
|
|
||||||
|
#### Implementazione
|
||||||
|
|
||||||
|
| Pezzo | Ruolo |
|
||||||
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
|
||||||
|
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||||
|
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
||||||
|
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||||
|
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||||
|
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||||
|
|
||||||
|
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||||
|
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||||
|
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||||
|
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||||
|
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||||
|
i profili.
|
||||||
|
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
|
||||||
|
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||||
|
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||||
|
di anno nel calcolo dei giorni.
|
||||||
|
|
||||||
## Profilo
|
## Profilo
|
||||||
|
|
||||||
Il profilo viene suddiviso in sette aree.
|
Il profilo viene suddiviso in sette aree.
|
||||||
@@ -132,6 +254,10 @@ Upload.
|
|||||||
|
|
||||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||||
|
|
||||||
|
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||||
|
riceve un avviso nella propria Home e compare in quello degli admin (vedi
|
||||||
|
[Avviso certificati](#avviso-certificati)).
|
||||||
|
|
||||||
Lo storico non viene mantenuto nella prima versione.
|
Lo storico non viene mantenuto nella prima versione.
|
||||||
|
|
||||||
### Foto tessera
|
### Foto tessera
|
||||||
@@ -255,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
|||||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||||
Non carica file al posto di altri.
|
Non carica file al posto di altri.
|
||||||
|
|
||||||
|
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||||
|
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||||
|
|
||||||
## Versione 1
|
## Versione 1
|
||||||
|
|
||||||
- Profilo giocatore
|
- Profilo giocatore
|
||||||
@@ -269,6 +398,7 @@ Non carica file al posto di altri.
|
|||||||
## Versioni future
|
## Versioni future
|
||||||
|
|
||||||
- Storico certificati medici
|
- Storico certificati medici
|
||||||
|
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||||
- Gestione documenti aggiuntivi
|
- Gestione documenti aggiuntivi
|
||||||
- Consensi privacy
|
- Consensi privacy
|
||||||
- Firma digitale
|
- Firma digitale
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "crapp",
|
"name": "crapp",
|
||||||
"version": "1.0.0",
|
"version": "1.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { useProfili } from "@/lib/profili";
|
||||||
|
import {
|
||||||
|
avvisiCertificati,
|
||||||
|
formatDataBreve,
|
||||||
|
testoScadenza,
|
||||||
|
type AvvisoCertificato,
|
||||||
|
} from "@/lib/profili-core";
|
||||||
|
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
|
import { oggiISO } from "@/lib/palloni-core";
|
||||||
|
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||||
|
import { useGiocatoreBase } from "@/lib/user-store";
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
colore,
|
||||||
|
titolo,
|
||||||
|
righe,
|
||||||
|
cliccabile,
|
||||||
|
}: {
|
||||||
|
colore: "warning" | "primary";
|
||||||
|
titolo: string;
|
||||||
|
righe: string[];
|
||||||
|
cliccabile?: boolean;
|
||||||
|
}) {
|
||||||
|
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||||
|
colore === "warning"
|
||||||
|
? "bg-warning text-warning-foreground"
|
||||||
|
: "bg-primary text-primary-foreground"
|
||||||
|
} ${cliccabile ? "premi" : ""}`;
|
||||||
|
const contenuto = (
|
||||||
|
<>
|
||||||
|
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||||
|
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||||
|
</p>
|
||||||
|
{righe.map((r) => (
|
||||||
|
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||||
|
{r}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return cliccabile ? (
|
||||||
|
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||||
|
{contenuto}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className={classi}>{contenuto}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
|
||||||
|
export function AvvisoCertificati() {
|
||||||
|
const admin = useIsAdmin();
|
||||||
|
const ruoliPronti = useRuoliPronti();
|
||||||
|
const base = useGiocatoreBase();
|
||||||
|
const { righe: rosa } = useGiocatoriSquadra();
|
||||||
|
const { profili, isPending } = useProfili();
|
||||||
|
|
||||||
|
if (!ruoliPronti || isPending) return null;
|
||||||
|
|
||||||
|
const oggi = oggiISO();
|
||||||
|
|
||||||
|
if (admin) {
|
||||||
|
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
|
||||||
|
if (scaduti.length === 0 && inScadenza.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{scaduti.length > 0 ? (
|
||||||
|
<Card
|
||||||
|
colore="primary"
|
||||||
|
titolo="Certificati scaduti"
|
||||||
|
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{inScadenza.length > 0 ? (
|
||||||
|
<Card
|
||||||
|
colore="warning"
|
||||||
|
titolo="Certificati in scadenza"
|
||||||
|
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!base) return null;
|
||||||
|
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
|
||||||
|
const mio = scaduti[0] ?? inScadenza[0];
|
||||||
|
if (!mio) return null;
|
||||||
|
|
||||||
|
return mio.giorni < 0 ? (
|
||||||
|
<Card
|
||||||
|
colore="primary"
|
||||||
|
titolo="Certificato scaduto"
|
||||||
|
cliccabile
|
||||||
|
righe={[
|
||||||
|
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
colore="warning"
|
||||||
|
titolo="Certificato in scadenza"
|
||||||
|
cliccabile
|
||||||
|
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||||
|
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||||
|
}
|
||||||
@@ -153,6 +153,10 @@ export function BarraSottosezioni({
|
|||||||
dragElastic={0.12}
|
dragElastic={0.12}
|
||||||
dragMomentum={false}
|
dragMomentum={false}
|
||||||
onDragEnd={(_, info) => {
|
onDragEnd={(_, info) => {
|
||||||
|
// Uno scroll verticale (anche veloce) ha sempre offset.y ben maggiore di
|
||||||
|
// offset.x: la velocity.x, invece, può essere rumorosa su gesti rapidi e da
|
||||||
|
// sola non basta a distinguere uno swipe reale da un semplice scroll in giù.
|
||||||
|
if (Math.abs(info.offset.x) <= Math.abs(info.offset.y)) return;
|
||||||
const arrivo = info.offset.x + proietta(info.velocity.x);
|
const arrivo = info.offset.x + proietta(info.velocity.x);
|
||||||
if (arrivo < -60) vaiA(indice + 1);
|
if (arrivo < -60) vaiA(indice + 1);
|
||||||
else if (arrivo > 60) vaiA(indice - 1);
|
else if (arrivo > 60) vaiA(indice - 1);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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 type { Evento } from "@/lib/eventi";
|
||||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||||
import { dataOggi } from "@/lib/scout-live";
|
import { dataOggi } from "@/lib/scout-live";
|
||||||
|
|
||||||
@@ -26,9 +26,7 @@ const tipoMeta = {
|
|||||||
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
|
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
|
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||||
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
|
|
||||||
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
|
||||||
|
|
||||||
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
|
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
|
||||||
const iconeStato: Record<Stato, typeof Check> = {
|
const iconeStato: Record<Stato, typeof Check> = {
|
||||||
@@ -51,6 +49,9 @@ export function linkPerEvento(e: Evento) {
|
|||||||
label: "Dettaglio allenamento",
|
label: "Dettaglio allenamento",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (e.tipo === "evento") {
|
||||||
|
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,15 +88,11 @@ export function EventoCard({
|
|||||||
const isCompleanno = evento.tipo === "compleanno";
|
const isCompleanno = evento.tipo === "compleanno";
|
||||||
const passato = evento.data < dataOggi();
|
const passato = evento.data < dataOggi();
|
||||||
const cliccabile = Boolean(linkTo);
|
const cliccabile = Boolean(linkTo);
|
||||||
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
|
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
|
||||||
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
|
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
|
||||||
const stati =
|
const stati = includeInfortunato(evento.tipo, stato)
|
||||||
evento.tipo === "evento"
|
? [...statiBase, "infortunato" as const]
|
||||||
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
|
: statiBase;
|
||||||
stato === "infortunato"
|
|
||||||
? [...statiEventoExtra, "infortunato" as const]
|
|
||||||
: statiEventoExtra
|
|
||||||
: statiSportivi;
|
|
||||||
|
|
||||||
if (isCompleanno) {
|
if (isCompleanno) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +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 { 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";
|
||||||
@@ -15,7 +16,16 @@ import { dataOggi } from "@/lib/scout-live";
|
|||||||
|
|
||||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||||
|
|
||||||
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
|
export function RosaPresenze({
|
||||||
|
eventoId,
|
||||||
|
data,
|
||||||
|
tipo = "allenamento",
|
||||||
|
}: {
|
||||||
|
eventoId: string;
|
||||||
|
data: string;
|
||||||
|
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
|
||||||
|
tipo?: EventoTipo;
|
||||||
|
}) {
|
||||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||||
const salva = useSalvaPresenza();
|
const salva = useSalvaPresenza();
|
||||||
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
||||||
@@ -32,6 +42,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
|||||||
const risposteN = rosa.length - mancanti.length;
|
const risposteN = rosa.length - mancanti.length;
|
||||||
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
|
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
|
||||||
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
|
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
|
||||||
|
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
|
||||||
|
|
||||||
async function sollecita() {
|
async function sollecita() {
|
||||||
setSollecito(true);
|
setSollecito(true);
|
||||||
@@ -67,7 +78,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
|||||||
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
|
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{ordine.map((s) => {
|
{ordineVisibile.map((s) => {
|
||||||
const n = rosa.filter((g) => risposte[g.id] === s).length;
|
const n = rosa.filter((g) => risposte[g.id] === s).length;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -92,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
|||||||
La tua risposta
|
La tua risposta
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
{ordine.map((s) => {
|
{ordineVisibile.map((s) => {
|
||||||
const attivo = risposte[io.id] === s;
|
const attivo = risposte[io.id] === s;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -140,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{ordine.map((s) => {
|
{ordineVisibile.map((s) => {
|
||||||
const lista = rosa.filter((g) => risposte[g.id] === s);
|
const lista = rosa.filter((g) => risposte[g.id] === s);
|
||||||
if (lista.length === 0) return null;
|
if (lista.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
|
|||||||
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
||||||
export type CandidatoTurno = { id: string; nome: string };
|
export type CandidatoTurno = { id: string; nome: string };
|
||||||
|
|
||||||
/** Eventi che richiedono i palloni (allenamenti, partite, extra), in ordine di data. */
|
/** Eventi che richiedono i palloni (solo allenamenti e partite), in ordine di data. */
|
||||||
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
export function eventiPalloni(eventi: Evento[]): Evento[] {
|
||||||
return eventi
|
return eventi
|
||||||
.filter((e) => e.tipo !== "compleanno")
|
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => a.data.localeCompare(b.data));
|
.sort((a, b) => a.data.localeCompare(b.data));
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ export function eventiPalloni(eventi: Evento[]): Evento[] {
|
|||||||
* meno volte (a parità, chi non lo fa da più tempo).
|
* meno volte (a parità, chi non lo fa da più tempo).
|
||||||
*
|
*
|
||||||
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
|
* Gli **allenamenti** non vengono proposti: restano «da assegnare» finché qualcuno
|
||||||
* non conferma un incaricato a mano. Su partite ed eventi extra la rotazione resta.
|
* non conferma un incaricato a mano. Sulle partite la rotazione resta.
|
||||||
*/
|
*/
|
||||||
export function completaTurni(
|
export function completaTurni(
|
||||||
turni: Record<string, string>,
|
turni: Record<string, string>,
|
||||||
|
|||||||
+11
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { supabase } from "@/integrations/supabase/client";
|
import { supabase } from "@/integrations/supabase/client";
|
||||||
import type { Stato } from "./crapp-data";
|
import type { Stato } from "./crapp-data";
|
||||||
import type { Evento } from "./eventi";
|
import type { Evento, EventoTipo } from "./eventi";
|
||||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||||
import { aggiornaSerie } from "./serie";
|
import { aggiornaSerie } from "./serie";
|
||||||
import { dataOggi } from "./scout-live";
|
import { dataOggi } from "./scout-live";
|
||||||
@@ -71,6 +71,16 @@ export function totaliEventiGiocatore(
|
|||||||
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
|
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Se "infortunato" va offerto come risposta: sì per allenamenti e partite, no per gli
|
||||||
|
* eventi extra-campo (`"evento"`, es. cena di squadra), dove l'infortunio non è una
|
||||||
|
* risposta pertinente. Resta comunque visibile a chi ce l'ha già (dati storici, o un
|
||||||
|
* evento cambiato tipo dopo la risposta), così può ancora toglierla.
|
||||||
|
*/
|
||||||
|
export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null): boolean {
|
||||||
|
return tipo !== "evento" || statoAttuale === "infortunato";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
|
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
|
||||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||||
|
|||||||
+71
-1
@@ -1,5 +1,5 @@
|
|||||||
import { rigaCsv } from "./scout-export";
|
import { rigaCsv } from "./scout-export";
|
||||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||||
@@ -215,3 +215,73 @@ export function csvTesseramento(
|
|||||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soglia dell'avviso certificati in Home (DD-035). */
|
||||||
|
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
||||||
|
|
||||||
|
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
||||||
|
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
||||||
|
const a = new Date(`${scadenza}T00:00:00`);
|
||||||
|
const b = new Date(`${oggi}T00:00:00`);
|
||||||
|
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
||||||
|
export function formatDataBreve(iso: string): string {
|
||||||
|
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
||||||
|
export function testoScadenza(giorni: number): string {
|
||||||
|
if (giorni === 0) return "scade oggi";
|
||||||
|
if (giorni === 1) return "scade domani";
|
||||||
|
return `scade tra ${giorni} giorni`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvvisoCertificato = {
|
||||||
|
giocatoreId: string;
|
||||||
|
nome: string;
|
||||||
|
cognome: string;
|
||||||
|
scadenza: string;
|
||||||
|
giorni: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||||
|
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||||
|
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
||||||
|
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
||||||
|
*/
|
||||||
|
export function avvisiCertificati(
|
||||||
|
rosa: GiocatoreSquadra[],
|
||||||
|
profili: Record<string, Profilo>,
|
||||||
|
oggi: string,
|
||||||
|
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
||||||
|
const scaduti: AvvisoCertificato[] = [];
|
||||||
|
const inScadenza: AvvisoCertificato[] = [];
|
||||||
|
for (const g of rosa) {
|
||||||
|
if (!inRosa(g)) continue;
|
||||||
|
const p = profili[g.id];
|
||||||
|
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
||||||
|
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||||
|
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
|
||||||
|
const avviso: AvvisoCertificato = {
|
||||||
|
giocatoreId: g.id,
|
||||||
|
nome: g.nome,
|
||||||
|
cognome: g.cognome,
|
||||||
|
scadenza: p.certificatoScadenza,
|
||||||
|
giorni,
|
||||||
|
};
|
||||||
|
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
||||||
|
}
|
||||||
|
scaduti.sort(confrontaAvvisi);
|
||||||
|
inScadenza.sort(confrontaAvvisi);
|
||||||
|
return { scaduti, inScadenza };
|
||||||
|
}
|
||||||
+11
-3
@@ -24,15 +24,23 @@ async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
|||||||
return (data ?? []).map((r) => r.role as Ruolo);
|
return (data ?? []).map((r) => r.role as Ruolo);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useRuoli(): Ruolo[] {
|
function useRuoliQuery() {
|
||||||
const { utenteId } = useSessione();
|
const { utenteId } = useSessione();
|
||||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||||
const query = useQuery({
|
return useQuery({
|
||||||
queryKey: [...RUOLI_KEY, utenteId],
|
queryKey: [...RUOLI_KEY, utenteId],
|
||||||
queryFn: () => fetchRuoli(utenteId),
|
queryFn: () => fetchRuoli(utenteId),
|
||||||
staleTime: 30 * 60_000,
|
staleTime: 30 * 60_000,
|
||||||
});
|
});
|
||||||
return query.data ?? [];
|
}
|
||||||
|
|
||||||
|
function useRuoli(): Ruolo[] {
|
||||||
|
return useRuoliQuery().data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
|
||||||
|
export function useRuoliPronti(): boolean {
|
||||||
|
return !useRuoliQuery().isPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsAdmin(): boolean {
|
export function useIsAdmin(): boolean {
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
|
|||||||
import { Route as ScoutRouteImport } from './routes/scout'
|
import { Route as ScoutRouteImport } from './routes/scout'
|
||||||
import { Route as SquadraRouteImport } from './routes/squadra'
|
import { Route as SquadraRouteImport } from './routes/squadra'
|
||||||
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
|
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
|
||||||
|
import { Route as EventoIdRouteImport } from './routes/evento.$id'
|
||||||
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
|
||||||
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
|
||||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||||
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
|
||||||
|
import { Route as ApiPublicKeepaliveRouteImport } from './routes/api/public/keepalive'
|
||||||
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
|
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
|
||||||
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||||
@@ -81,6 +83,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
|
|||||||
path: '/allenamento/$id',
|
path: '/allenamento/$id',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const EventoIdRoute = EventoIdRouteImport.update({
|
||||||
|
id: '/evento/$id',
|
||||||
|
path: '/evento/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
|
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
|
||||||
id: '/partita-csi/$id',
|
id: '/partita-csi/$id',
|
||||||
path: '/partita-csi/$id',
|
path: '/partita-csi/$id',
|
||||||
@@ -101,6 +108,11 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
|||||||
path: '/api/public/csi',
|
path: '/api/public/csi',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiPublicKeepaliveRoute = ApiPublicKeepaliveRouteImport.update({
|
||||||
|
id: '/api/public/keepalive',
|
||||||
|
path: '/api/public/keepalive',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiPublicNotificaPersonalizzataRoute =
|
const ApiPublicNotificaPersonalizzataRoute =
|
||||||
ApiPublicNotificaPersonalizzataRouteImport.update({
|
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||||
id: '/api/public/notifica-personalizzata',
|
id: '/api/public/notifica-personalizzata',
|
||||||
@@ -152,10 +164,12 @@ export interface FileRoutesByFullPath {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/evento/$id': typeof EventoIdRoute
|
||||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
@@ -175,10 +189,12 @@ export interface FileRoutesByTo {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/evento/$id': typeof EventoIdRoute
|
||||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
@@ -199,10 +215,12 @@ export interface FileRoutesById {
|
|||||||
'/scout': typeof ScoutRoute
|
'/scout': typeof ScoutRoute
|
||||||
'/squadra': typeof SquadraRoute
|
'/squadra': typeof SquadraRoute
|
||||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||||
|
'/evento/$id': typeof EventoIdRoute
|
||||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||||
'/partita/$id': typeof PartitaIdRoute
|
'/partita/$id': typeof PartitaIdRoute
|
||||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||||
|
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||||
@@ -224,10 +242,12 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/evento/$id'
|
||||||
| '/partita-csi/$id'
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/keepalive'
|
||||||
| '/api/public/notifica-personalizzata'
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
@@ -247,10 +267,12 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/evento/$id'
|
||||||
| '/partita-csi/$id'
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/keepalive'
|
||||||
| '/api/public/notifica-personalizzata'
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
@@ -270,10 +292,12 @@ export interface FileRouteTypes {
|
|||||||
| '/scout'
|
| '/scout'
|
||||||
| '/squadra'
|
| '/squadra'
|
||||||
| '/allenamento/$id'
|
| '/allenamento/$id'
|
||||||
|
| '/evento/$id'
|
||||||
| '/partita-csi/$id'
|
| '/partita-csi/$id'
|
||||||
| '/partita/$id'
|
| '/partita/$id'
|
||||||
| '/api/public/apri-sondaggio'
|
| '/api/public/apri-sondaggio'
|
||||||
| '/api/public/csi'
|
| '/api/public/csi'
|
||||||
|
| '/api/public/keepalive'
|
||||||
| '/api/public/notifica-personalizzata'
|
| '/api/public/notifica-personalizzata'
|
||||||
| '/api/public/notifiche-attive'
|
| '/api/public/notifiche-attive'
|
||||||
| '/api/public/promemoria-palloni'
|
| '/api/public/promemoria-palloni'
|
||||||
@@ -294,10 +318,12 @@ export interface RootRouteChildren {
|
|||||||
ScoutRoute: typeof ScoutRoute
|
ScoutRoute: typeof ScoutRoute
|
||||||
SquadraRoute: typeof SquadraRoute
|
SquadraRoute: typeof SquadraRoute
|
||||||
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
||||||
|
EventoIdRoute: typeof EventoIdRoute
|
||||||
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
|
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
|
||||||
PartitaIdRoute: typeof PartitaIdRoute
|
PartitaIdRoute: typeof PartitaIdRoute
|
||||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||||
|
ApiPublicKeepaliveRoute: typeof ApiPublicKeepaliveRoute
|
||||||
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||||
@@ -379,6 +405,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AllenamentoIdRouteImport
|
preLoaderRoute: typeof AllenamentoIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/evento/$id': {
|
||||||
|
id: '/evento/$id'
|
||||||
|
path: '/evento/$id'
|
||||||
|
fullPath: '/evento/$id'
|
||||||
|
preLoaderRoute: typeof EventoIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/partita-csi/$id': {
|
'/partita-csi/$id': {
|
||||||
id: '/partita-csi/$id'
|
id: '/partita-csi/$id'
|
||||||
path: '/partita-csi/$id'
|
path: '/partita-csi/$id'
|
||||||
@@ -407,6 +440,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/api/public/keepalive': {
|
||||||
|
id: '/api/public/keepalive'
|
||||||
|
path: '/api/public/keepalive'
|
||||||
|
fullPath: '/api/public/keepalive'
|
||||||
|
preLoaderRoute: typeof ApiPublicKeepaliveRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/public/notifica-personalizzata': {
|
'/api/public/notifica-personalizzata': {
|
||||||
id: '/api/public/notifica-personalizzata'
|
id: '/api/public/notifica-personalizzata'
|
||||||
path: '/api/public/notifica-personalizzata'
|
path: '/api/public/notifica-personalizzata'
|
||||||
@@ -470,10 +510,12 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ScoutRoute: ScoutRoute,
|
ScoutRoute: ScoutRoute,
|
||||||
SquadraRoute: SquadraRoute,
|
SquadraRoute: SquadraRoute,
|
||||||
AllenamentoIdRoute: AllenamentoIdRoute,
|
AllenamentoIdRoute: AllenamentoIdRoute,
|
||||||
|
EventoIdRoute: EventoIdRoute,
|
||||||
PartitaCsiIdRoute: PartitaCsiIdRoute,
|
PartitaCsiIdRoute: PartitaCsiIdRoute,
|
||||||
PartitaIdRoute: PartitaIdRoute,
|
PartitaIdRoute: PartitaIdRoute,
|
||||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||||
|
ApiPublicKeepaliveRoute: ApiPublicKeepaliveRoute,
|
||||||
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function AllenamentoDetail() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chiamata dal Vercel Cron Job (vedi vercel.json) per tenere attivo il progetto Supabase
|
||||||
|
* free tier, che va in pausa dopo 7 giorni senza richieste API. Una lettura minima basta:
|
||||||
|
* nessun dato sensibile, nessun effetto collaterale, quindi nessuna autenticazione richiesta.
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute("/api/public/keepalive")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async () => {
|
||||||
|
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from("giocatori_squadra")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.limit(1);
|
||||||
|
if (error) {
|
||||||
|
console.error("keepalive", error);
|
||||||
|
return new Response("Errore", { status: 500 });
|
||||||
|
}
|
||||||
|
return new Response("ok");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { MapPin, Clock, Users, PartyPopper } from "lucide-react";
|
||||||
|
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||||
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||||
|
import { useRosa } from "@/lib/rosa";
|
||||||
|
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
|
||||||
|
import { usePresenzeEvento } from "@/lib/presenze";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/evento/$id")({
|
||||||
|
head: () => {
|
||||||
|
const titolo = "Dettaglio evento";
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: `${titolo} — CrAPP` },
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||||
|
},
|
||||||
|
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||||
|
{
|
||||||
|
property: "og:description",
|
||||||
|
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||||
|
},
|
||||||
|
{ property: "og:type", content: "website" },
|
||||||
|
{ name: "twitter:card", content: "summary" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
component: EventoDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
function EventoDetail() {
|
||||||
|
const { id } = Route.useParams();
|
||||||
|
const { evento } = useEvento(id);
|
||||||
|
const { risposte } = usePresenzeEvento(id);
|
||||||
|
const rosa = useRosa();
|
||||||
|
const presentiVeri = rosa.filter(
|
||||||
|
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (!evento) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 pt-8">
|
||||||
|
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader titolo="Evento" sottotitolo={formatData(evento.data)} />
|
||||||
|
|
||||||
|
<Section titolo={evento.titolo}>
|
||||||
|
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-warning/15 text-warning">
|
||||||
|
<PartyPopper className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Evento extra-campo
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" /> {evento.ora}
|
||||||
|
</span>
|
||||||
|
<span className="ml-4 inline-flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{evento.note ? (
|
||||||
|
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||||
|
{evento.note}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section titolo="Rosa e presenze">
|
||||||
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Flame, ChevronRight } from "lucide-react";
|
import { Flame, ChevronRight } from "lucide-react";
|
||||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||||
|
import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
|
||||||
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||||
import { Reveal } from "@/components/motion/Reveal";
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
@@ -120,6 +121,8 @@ function Index() {
|
|||||||
|
|
||||||
<PromemoriaPalloni />
|
<PromemoriaPalloni />
|
||||||
|
|
||||||
|
<AvvisoCertificati />
|
||||||
|
|
||||||
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ function PartitaDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Section titolo="Rosa e presenze">
|
<Section titolo="Rosa e presenze">
|
||||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+40
-24
@@ -130,31 +130,47 @@ function Squadra() {
|
|||||||
const contenutoRosa = useMemo(
|
const contenutoRosa = useMemo(
|
||||||
() => (
|
() => (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allenatori.map((a) => (
|
{allenatori.map((a) => {
|
||||||
<article
|
const isOpen = aperto === a.id;
|
||||||
key={a.id}
|
return (
|
||||||
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
|
<article key={a.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||||
>
|
<button
|
||||||
<Avatar
|
type="button"
|
||||||
id={a.id}
|
onClick={() => setAperto(isOpen ? null : a.id)}
|
||||||
fallback={inizialiDa(nomeCompleto(a))}
|
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||||
className="h-11 w-11 text-lg"
|
aria-expanded={isOpen}
|
||||||
/>
|
>
|
||||||
<span className="min-w-0 flex-1">
|
<Avatar
|
||||||
<span className="block truncate text-sm font-bold leading-tight">
|
id={a.id}
|
||||||
{nomeCompleto(a)}
|
fallback={inizialiDa(nomeCompleto(a))}
|
||||||
</span>
|
className="h-11 w-11 text-lg"
|
||||||
<span className="mt-1 flex items-center gap-2">
|
/>
|
||||||
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
<span className="min-w-0 flex-1">
|
||||||
{a.nascita ? (
|
<span className="block truncate text-sm font-bold leading-tight">
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
{nomeCompleto(a)}
|
||||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span className="mt-1 flex items-center gap-2">
|
||||||
</span>
|
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</span>
|
||||||
))}
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||||
|
isOpen && "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && a.nascita ? (
|
||||||
|
<div className="border-t border-border px-4 pb-4 pt-3">
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{rosa.map((g) => {
|
{rosa.map((g) => {
|
||||||
const stati = badgeGiocatore(g);
|
const stati = badgeGiocatore(g);
|
||||||
const sbloccati = sonoAllenatore
|
const sbloccati = sonoAllenatore
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
contaPartiteGiocate,
|
contaPartiteGiocate,
|
||||||
contaPresenzeGiocatore,
|
contaPresenzeGiocatore,
|
||||||
destinatariSollecito,
|
destinatariSollecito,
|
||||||
|
includeInfortunato,
|
||||||
serieConferme,
|
serieConferme,
|
||||||
serieConsecutiva,
|
serieConsecutiva,
|
||||||
totaliEventiGiocatore,
|
totaliEventiGiocatore,
|
||||||
@@ -295,4 +296,24 @@ const conAltri2 = conRisposta(
|
|||||||
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
|
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
|
||||||
assert.equal(conAltri2.tempi["e2"]?.["g2"], POI);
|
assert.equal(conAltri2.tempi["e2"]?.["g2"], POI);
|
||||||
|
|
||||||
|
// --- includeInfortunato: "infortunato" fuori per gli eventi extra-campo -------
|
||||||
|
assert.equal(includeInfortunato("allenamento"), true, "allenamento: infortunato è un'opzione");
|
||||||
|
assert.equal(includeInfortunato("partita"), true, "partita: infortunato è un'opzione");
|
||||||
|
assert.equal(includeInfortunato("evento"), false, "evento extra-campo: niente infortunato");
|
||||||
|
assert.equal(
|
||||||
|
includeInfortunato("evento", "infortunato"),
|
||||||
|
true,
|
||||||
|
"chi ha già risposto infortunato lo vede ancora, per poterlo togliere",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
includeInfortunato("evento", "presente"),
|
||||||
|
false,
|
||||||
|
"un'altra risposta esistente non riapre l'opzione",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
includeInfortunato("evento", undefined),
|
||||||
|
false,
|
||||||
|
"nessuna risposta: l'opzione resta chiusa per gli eventi extra-campo",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("presenze: ok");
|
console.log("presenze: ok");
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
aRigaProfilo,
|
aRigaProfilo,
|
||||||
|
avvisiCertificati,
|
||||||
completamento,
|
completamento,
|
||||||
completamentoAllenatore,
|
completamentoAllenatore,
|
||||||
csvTesseramento,
|
csvTesseramento,
|
||||||
daRigaProfilo,
|
daRigaProfilo,
|
||||||
|
formatDataBreve,
|
||||||
|
giorniAllaScadenza,
|
||||||
sezioniComplete,
|
sezioniComplete,
|
||||||
statoScadenza,
|
statoScadenza,
|
||||||
|
testoScadenza,
|
||||||
type Profilo,
|
type Profilo,
|
||||||
} from "@/lib/profili-core";
|
} from "@/lib/profili-core";
|
||||||
import {
|
import {
|
||||||
@@ -214,4 +218,119 @@ assert.equal(
|
|||||||
);
|
);
|
||||||
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
||||||
|
|
||||||
|
// --- avviso certificati (DD-035) ------------------------------------------------
|
||||||
|
assert.equal(giorniAllaScadenza("2026-09-27", "2026-09-20"), 7);
|
||||||
|
assert.equal(giorniAllaScadenza("2026-09-19", "2026-09-20"), -1, "scaduto ieri");
|
||||||
|
assert.equal(giorniAllaScadenza("2026-09-20", "2026-09-20"), 0, "scade oggi");
|
||||||
|
assert.equal(giorniAllaScadenza("2026-10-01", "2026-09-30"), 1, "cambio di mese");
|
||||||
|
assert.equal(giorniAllaScadenza("2027-01-01", "2026-12-31"), 1, "cambio di anno");
|
||||||
|
|
||||||
|
assert.equal(formatDataBreve("2026-09-12"), "12/09/2026");
|
||||||
|
|
||||||
|
assert.equal(testoScadenza(0), "scade oggi");
|
||||||
|
assert.equal(testoScadenza(1), "scade domani");
|
||||||
|
assert.equal(testoScadenza(5), "scade tra 5 giorni");
|
||||||
|
|
||||||
|
const rosaCertificati: GiocatoreSquadra[] = [
|
||||||
|
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari", attivo: true, tipo: "giocatore" },
|
||||||
|
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni", attivo: true, tipo: "giocatore" },
|
||||||
|
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi", attivo: true, tipo: "giocatore" },
|
||||||
|
{ ...squadra[0]!, id: "g4", nome: "Sara", cognome: "Neri", attivo: false, tipo: "giocatore" },
|
||||||
|
{ ...squadra[0]!, id: "g5", nome: "Luca", cognome: "Bianchi", attivo: true, tipo: "allenatore" },
|
||||||
|
];
|
||||||
|
const oggiTest = "2026-09-20";
|
||||||
|
const profiliCertificati: Record<string, Profilo> = {
|
||||||
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
|
||||||
|
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
|
||||||
|
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
|
||||||
|
g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
|
||||||
|
g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
|
||||||
|
};
|
||||||
|
const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
|
||||||
|
assert.deepEqual(
|
||||||
|
avvisi.scaduti.map((a) => a.giocatoreId),
|
||||||
|
["g2"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
avvisi.inScadenza.map((a) => a.giocatoreId),
|
||||||
|
["g1"],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
avvisiCertificati(
|
||||||
|
[{ ...squadra[0]!, id: "g6" }],
|
||||||
|
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
||||||
|
oggiTest,
|
||||||
|
).inScadenza.length,
|
||||||
|
0,
|
||||||
|
"certificato mancante: nessun avviso",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dueInScadenza: Record<string, Profilo> = {
|
||||||
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
||||||
|
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
|
||||||
|
};
|
||||||
|
const ordinati = avvisiCertificati(
|
||||||
|
[
|
||||||
|
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||||
|
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||||
|
],
|
||||||
|
dueInScadenza,
|
||||||
|
oggiTest,
|
||||||
|
).inScadenza.map((a) => a.giocatoreId);
|
||||||
|
assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
|
||||||
|
|
||||||
|
const pariGiorni: Record<string, Profilo> = {
|
||||||
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
|
||||||
|
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
|
||||||
|
};
|
||||||
|
const alfabetico = avvisiCertificati(
|
||||||
|
[
|
||||||
|
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||||
|
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||||
|
],
|
||||||
|
pariGiorni,
|
||||||
|
oggiTest,
|
||||||
|
).inScadenza.map((a) => a.giocatoreId);
|
||||||
|
assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
|
||||||
|
|
||||||
|
// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
|
||||||
|
const rosaSoglia: GiocatoreSquadra[] = [
|
||||||
|
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||||
|
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||||
|
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||||
|
];
|
||||||
|
const profiliSoglia: Record<string, Profilo> = {
|
||||||
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
|
||||||
|
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
|
||||||
|
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
|
||||||
|
};
|
||||||
|
const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
|
||||||
|
assert.deepEqual(
|
||||||
|
soglia.inScadenza.map((a) => a.giocatoreId),
|
||||||
|
["g3", "g2"],
|
||||||
|
"8 giorni resta valido, 7 e 0 generano avviso",
|
||||||
|
);
|
||||||
|
assert.equal(soglia.scaduti.length, 0);
|
||||||
|
|
||||||
|
// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
|
||||||
|
const rosaScaduti: GiocatoreSquadra[] = [
|
||||||
|
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||||
|
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||||
|
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||||
|
];
|
||||||
|
const profiliScaduti: Record<string, Profilo> = {
|
||||||
|
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
|
||||||
|
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
|
||||||
|
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
|
||||||
|
};
|
||||||
|
const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
|
||||||
|
(a) => a.giocatoreId,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
scadutiOrdinati,
|
||||||
|
["g2", "g1", "g3"],
|
||||||
|
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
|
||||||
|
);
|
||||||
|
|
||||||
console.log("profili-core: ok");
|
console.log("profili-core: ok");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/public/keepalive",
|
||||||
|
"schedule": "0 5 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user