9 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
21 changed files with 397 additions and 90 deletions

No files matched your search

+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
+31
View File
@@ -13,6 +13,37 @@ 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 ## [1.1.0] - 2026-09-27
### Aggiunto ### Aggiunto
+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
+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`)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "crapp", "name": "crapp",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
@@ -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
+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>
</>
);
}
+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");
+8
View File
@@ -0,0 +1,8 @@
{
"crons": [
{
"path": "/api/public/keepalive",
"schedule": "0 5 * * *"
}
]
}