12 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 39918f2eb7 Scheda di dettaglio per gli eventi extra-campo, senza infortunato tra le risposte
Gli eventi generici (es. cena di squadra) avevano solo il conteggio presenze in
card, senza modo di vedere chi ha risposto. Aggiunta la route /evento/$id sul
modello di allenamento/partita, con RosaPresenze per l'elenco nominativo.

Lo stato "infortunato" non è una risposta pertinente per questi eventi: escluso
da badge, gruppi e bottoni di risposta sia in RosaPresenze che in EventoCard,
tramite la funzione condivisa includeInfortunato() in presenze.ts.

Documentazione dei moduli (calendario, presenze, palloni, badge) allineata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 11:01:46 +02:00
davideandClaude Sonnet 5 a72eb087d1 Esclude gli eventi generici dal turno palloni
eventiPalloni() ora include solo allenamenti e partite: una cena di
squadra o altro evento generico non genera più proposte automatiche,
promemoria push o conteggi per il turno palloni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 10:40:16 +02:00
davideandClaude Sonnet 5 34edfc662e Il precedente fix dello swipe non bastava: guarda anche offset.y
dragDirectionLock non ha effetto quando drag è già vincolato a un solo
asse ("x"): il gesto veniva comunque interpretato come swipe orizzontale
sulla sola velocity.x, rumorosa nei flick verticali veloci (bug visto
sia verso Obiettivi che verso Rosa, segno che il rumore cambiava segno).
Ora onDragEnd ignora il gesto se lo spostamento verticale (offset.y) è
maggiore di quello orizzontale (offset.x): uno scroll resta sempre
dominante in verticale, uno swipe intenzionale in orizzontale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:23:06 +02:00
davideandClaude Sonnet 5 e0a9d19a02 Porta la versione a 1.1.1: fix swipe tab e keepalive/backup già rilasciati
Aggiunge dragDirectionLock alla tab bar (Squadra > Stats non salta più su
Obiettivi durante uno scroll verticale veloce) e aggiorna il changelog
con quanto già presente su main dalla 1.1.0: keepalive giornaliero,
cifratura di default del backup e la card allenatore uniformata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:19:25 +02:00
davideandClaude Sonnet 5 3c2f9cf876 Uniforma la riga collassata della rosa tra allenatore e giocatori
La card dell'allenatore mostrava subito nome, ruolo e data di nascita,
mentre quella dei giocatori mostra solo nome e ruolo lasciando i
dettagli (nascita, statistiche) dietro l'espansione. Ora anche
l'allenatore è una card espandibile identica, con la data di nascita
visibile solo aprendola.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 09:13:10 +02:00
davideandClaude Sonnet 5 93d70f4879 Usa bun invece di node per lo step di download storage nel backup
download-storage.mjs importa storage-core.ts, che node non può caricare
senza un loader TS; bun lo supporta nativamente ed è già una dipendenza
del progetto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 18:51:23 +02:00
davideandClaude Sonnet 5 20ca6ba5a6 Aggiunge un keepalive giornaliero per evitare la pausa Supabase free tier
Il progetto Supabase free va in pausa dopo 7 giorni senza richieste API.
Un Vercel Cron Job chiama /api/public/keepalive una volta al giorno, che
fa una lettura minima per tenere il progetto attivo senza servizi esterni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:43:28 +02:00
davideandClaude Sonnet 5 342b457ee7 Cifra il backup di default e aggiunge tar.gz + istruzioni decompressione
Prima --encrypt era opt-in; dato che i dump contengono dati sensibili
(hash password, documenti d'identità) la cifratura ora è il comportamento
di default, disattivabile con --no-encrypt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:34:57 +02:00
davide a6defc1a49 Merge branch 'develop' into main: versione 1.1.0 con l'avviso certificati 2026-09-27 14:10:06 +02:00
davideandClaude Sonnet 5 0a2112a9f3 Porta la versione a 1.1.0 per l'avviso certificati (DD-035)
Aggiunta la sezione [1.1.0] nel changelog, spostata la voce da «Prossimo» a
«Fatto» nella roadmap e chiuso lo stato di DD-035 come implementata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:09:02 +02:00
davideandClaude Sonnet 5 a51cdb43e4 Implementa l'avviso certificati in scadenza in Home (DD-035)
Calcolato al volo da certificato_scadenza in profili-core.ts: nessuna tabella,
nessuna migration, nessuna push. Lo vedono solo gli admin (nomi di tutta la
rosa, non cliccabile) e il giocatore interessato (solo il proprio, apre
Documenti). Montato in Home subito dopo il promemoria palloni.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 14:06:18 +02:00
davideandClaude Opus 5.5 49d4d9de31 Documenta l'avviso certificati in scadenza in Home (DD-035)
Specifica in profilo-giocatore.md: avviso giallo nei 7 giorni prima della
scadenza e nero dopo, con i nomi di tutta la rosa per gli admin (non
cliccabile) e solo il proprio per il giocatore interessato (apre Documenti).
Calcolato al volo da certificato_scadenza: nessuna migration, nessuna push.
Aggiornati roadmap, stato del progetto, allenatore e notifiche.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 15:43:26 +02:00
32 changed files with 942 additions and 113 deletions

No files matched your search

+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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:
+59
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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
+6 -5
View File
@@ -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.
--- ---
+11 -3
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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`.
--- ---
+8 -4
View File
@@ -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`)
+131 -1
View File
@@ -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
View File
@@ -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",
+113
View File
@@ -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);
+9 -12
View File
@@ -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 (
+15 -4
View File
@@ -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 (
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+42
View File
@@ -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,
+1 -1
View File
@@ -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>
</> </>
); );
+25
View File
@@ -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");
},
},
},
});
+95
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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");
+119
View File
@@ -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");
+8
View File
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/public/keepalive",
"schedule": "0 5 * * *"
}
]
}