Compare commits
9
Commits
0a2112a9f3
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39918f2eb7 | ||
|
|
a72eb087d1 | ||
|
|
34edfc662e | ||
|
|
e0a9d19a02 | ||
|
|
3c2f9cf876 | ||
|
|
93d70f4879 | ||
|
|
20ca6ba5a6 | ||
|
|
342b457ee7 | ||
|
|
a6defc1a49 |
No files matched your search
+33
-12
@@ -35,7 +35,7 @@ Da fare una volta sola per host (poi il backup si rilancia semplicemente con
|
||||
Project Settings → API. La **service role key** è quella con permessi pieni
|
||||
(bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai
|
||||
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
|
||||
gpg --version
|
||||
```
|
||||
@@ -46,15 +46,21 @@ A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto).
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/ con tutto dentro
|
||||
./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico
|
||||
# backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||
# passphrase) e cancella la cartella in chiaro
|
||||
./backups/backup.sh # crea backups/YYYYMMDD_HHMM/, poi comprime e cifra
|
||||
# in backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una
|
||||
# passphrase a te scelta) e cancella cartella+tar.gz
|
||||
# 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
|
||||
precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano
|
||||
quando non servono più.
|
||||
La passphrase non è salvata da nessuna parte: la scegli tu al momento (gpg la
|
||||
chiede due volte, per conferma) e ti serve identica per decifrare in seguito —
|
||||
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),
|
||||
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 |
|
||||
| `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
|
||||
|
||||
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
|
||||
GitHub e Gitea).
|
||||
|
||||
Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`.
|
||||
|
||||
## Cosa NON è incluso
|
||||
|
||||
- **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:
|
||||
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
|
||||
l'archivio a mano;
|
||||
- se sposti un backup fuori da questo host, usa l'archivio cifrato (default)
|
||||
invece del tar.gz in chiaro generato con `--no-encrypt`;
|
||||
- cancella i backup in chiaro quando non ti servono più.
|
||||
|
||||
## File di questa cartella
|
||||
|
||||
+16
-9
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage.
|
||||
# Uso:
|
||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto
|
||||
# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase)
|
||||
# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/, 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
|
||||
# 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
|
||||
|
||||
@@ -29,15 +30,21 @@ echo "==> Dump dati auth (utenti)"
|
||||
npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql"
|
||||
|
||||
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"
|
||||
|
||||
if [[ "${1:-}" == "--encrypt" ]]; then
|
||||
ARCHIVE="$RUN_DIR.tar.gz.gpg"
|
||||
echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)"
|
||||
tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE"
|
||||
rm -rf "$RUN_DIR"
|
||||
TARBALL="$RUN_DIR.tar.gz"
|
||||
echo "==> Comprimo in $TARBALL"
|
||||
tar -C "$SCRIPT_DIR" -czf "$TARBALL" "$STAMP"
|
||||
|
||||
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 " Per estrarlo: gpg -d $ARCHIVE | tar xz"
|
||||
fi
|
||||
@@ -13,6 +13,37 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -408,11 +408,12 @@ meccanismo:
|
||||
`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.
|
||||
- **`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
|
||||
dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei palloni. Rischio
|
||||
teorico basso (l'UI non offre questa combinazione), comportamento 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.
|
||||
`eventiPalloni()` che include solo partite e allenamenti. Un turno registrato per errore su
|
||||
un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni. Rischio teorico basso — l'UI non offre questa combinazione, dato che
|
||||
`TurnoPalloni`/`completaTurni()`/i promemoria ora si fermano a `eventiPalloni()` — comportamento
|
||||
pinnato da un test dedicato in `palloni-core.test.ts` così che un domani, se serve stringere,
|
||||
non lo si scopra rompendo un test esistente ma leggendo perché quel test lo dimostrava apposta.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||
`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`
|
||||
|
||||
---
|
||||
@@ -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;
|
||||
`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
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, nessun link per eventi ed
|
||||
eventi virtuali (compleanni).
|
||||
`collegamento-csi.md`), `/allenamento/$id` per un allenamento, `/evento/$id` per un evento
|
||||
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
|
||||
|
||||
|
||||
+16
-10
@@ -11,7 +11,8 @@
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** o evento extra senza
|
||||
turno già salvato, 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).
|
||||
- `eventiPalloni()` (`palloni-core.ts`) è il filtro comune a tutta la pipeline (proposte,
|
||||
conteggi, serie, promemoria): include solo `tipo === "partita"` e `tipo === "allenamento"`,
|
||||
scarta eventi extra-campo e compleanni.
|
||||
- `completaTurni()` (`palloni-core.ts`) propone, per ogni **partita** senza turno già salvato,
|
||||
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
|
||||
upsert su `evento_id`.
|
||||
- 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
|
||||
"assente" o "infortunato" per quell'evento.
|
||||
- **`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
|
||||
evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge Sherpa dei
|
||||
palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa combinazione — ma
|
||||
il comportamento attuale è pinnato da un test dedicato in `palloni-core.test.ts`.
|
||||
include solo partite e allenamenti): guarda solo `e.data < oggi`. Un turno registrato per
|
||||
errore su un evento fuori dal dominio "richiede i palloni" conterebbe comunque per il badge
|
||||
Sherpa dei palloni (`badge.md` § Problemi noti). Rischio basso — l'UI non offre questa
|
||||
combinazione, dato che né `TurnoPalloni` né i promemoria automatici propongono più un turno
|
||||
per eventi extra-campo o compleanni — ma il comportamento attuale è pinnato da un test
|
||||
dedicato in `palloni-core.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,8 +24,11 @@ attuale.
|
||||
Stati possibili (`Stato` in `src/lib/crapp-data.ts`): `presente`, `assente`, `forse`,
|
||||
`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
|
||||
risposto". Sulla card in home/calendario (`EventoCard`) lo stato `infortunato` è offerto solo
|
||||
per partite e allenamenti; sugli eventi extra-campo non è selezionabile (non pertinente).
|
||||
risposto". Lo stato `infortunato` è offerto solo per partite e allenamenti: sugli eventi
|
||||
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,
|
||||
↓ legge tutta la tabella)
|
||||
RosaPresenze → src/components/crapp/RosaPresenze.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato)
|
||||
allenamento.$id.tsx / partita.$id.tsx
|
||||
↑ montato da (riepilogo, bottoni di risposta, gruppi per stato;
|
||||
allenamento.$id.tsx / prop `tipo` esclude "infortunato" per gli eventi extra-campo)
|
||||
partita.$id.tsx / evento.$id.tsx
|
||||
|
||||
EventoCard → src/components/crapp/EventoCard.tsx
|
||||
↑ home / calendario (riga compatta di stati; senza `infortunato` se tipo `evento`)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -153,6 +153,10 @@ export function BarraSottosezioni({
|
||||
dragElastic={0.12}
|
||||
dragMomentum={false}
|
||||
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);
|
||||
if (arrivo < -60) vaiA(indice + 1);
|
||||
else if (arrivo > 60) vaiA(indice - 1);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
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 { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
@@ -26,9 +26,7 @@ const tipoMeta = {
|
||||
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
|
||||
} as const;
|
||||
|
||||
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
|
||||
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
|
||||
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
|
||||
|
||||
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
|
||||
const iconeStato: Record<Stato, typeof Check> = {
|
||||
@@ -51,6 +49,9 @@ export function linkPerEvento(e: Evento) {
|
||||
label: "Dettaglio allenamento",
|
||||
};
|
||||
}
|
||||
if (e.tipo === "evento") {
|
||||
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -87,15 +88,11 @@ export function EventoCard({
|
||||
const isCompleanno = evento.tipo === "compleanno";
|
||||
const passato = evento.data < dataOggi();
|
||||
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 stati =
|
||||
evento.tipo === "evento"
|
||||
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
|
||||
stato === "infortunato"
|
||||
? [...statiEventoExtra, "infortunato" as const]
|
||||
: statiEventoExtra
|
||||
: statiSportivi;
|
||||
const stati = includeInfortunato(evento.tipo, stato)
|
||||
? [...statiBase, "infortunato" as const]
|
||||
: statiBase;
|
||||
|
||||
if (isCompleanno) {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import type { EventoTipo } from "@/lib/eventi";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
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"];
|
||||
|
||||
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 salva = useSalvaPresenza();
|
||||
// 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 perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
|
||||
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() {
|
||||
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" />
|
||||
|
||||
<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;
|
||||
return (
|
||||
<span
|
||||
@@ -92,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
La tua risposta
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const attivo = risposte[io.id] === s;
|
||||
return (
|
||||
<button
|
||||
@@ -140,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{ordine.map((s) => {
|
||||
{ordineVisibile.map((s) => {
|
||||
const lista = rosa.filter((g) => risposte[g.id] === s);
|
||||
if (lista.length === 0) return null;
|
||||
return (
|
||||
|
||||
@@ -8,10 +8,10 @@ export type Turno = { evento_id: string; giocatore_id: string; aggiornato_da: st
|
||||
/** Candidato al turno palloni: solo id e nome bastano per assegnare e ordinare. */
|
||||
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[] {
|
||||
return eventi
|
||||
.filter((e) => e.tipo !== "compleanno")
|
||||
.filter((e) => e.tipo === "allenamento" || e.tipo === "partita")
|
||||
.slice()
|
||||
.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).
|
||||
*
|
||||
* 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(
|
||||
turni: Record<string, string>,
|
||||
|
||||
+11
-1
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
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 { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
@@ -71,6 +71,16 @@ export function totaliEventiGiocatore(
|
||||
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ù
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
|
||||
@@ -19,10 +19,12 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
|
||||
import { Route as ScoutRouteImport } from './routes/scout'
|
||||
import { Route as SquadraRouteImport } from './routes/squadra'
|
||||
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 PartitaIdRouteImport } from './routes/partita.$id'
|
||||
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
|
||||
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 ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
|
||||
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
|
||||
@@ -81,6 +83,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
|
||||
path: '/allenamento/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const EventoIdRoute = EventoIdRouteImport.update({
|
||||
id: '/evento/$id',
|
||||
path: '/evento/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
|
||||
id: '/partita-csi/$id',
|
||||
path: '/partita-csi/$id',
|
||||
@@ -101,6 +108,11 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
|
||||
path: '/api/public/csi',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicKeepaliveRoute = ApiPublicKeepaliveRouteImport.update({
|
||||
id: '/api/public/keepalive',
|
||||
path: '/api/public/keepalive',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiPublicNotificaPersonalizzataRoute =
|
||||
ApiPublicNotificaPersonalizzataRouteImport.update({
|
||||
id: '/api/public/notifica-personalizzata',
|
||||
@@ -152,10 +164,12 @@ export interface FileRoutesByFullPath {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
@@ -175,10 +189,12 @@ export interface FileRoutesByTo {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
@@ -199,10 +215,12 @@ export interface FileRoutesById {
|
||||
'/scout': typeof ScoutRoute
|
||||
'/squadra': typeof SquadraRoute
|
||||
'/allenamento/$id': typeof AllenamentoIdRoute
|
||||
'/evento/$id': typeof EventoIdRoute
|
||||
'/partita-csi/$id': typeof PartitaCsiIdRoute
|
||||
'/partita/$id': typeof PartitaIdRoute
|
||||
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
|
||||
'/api/public/csi': typeof ApiPublicCsiRoute
|
||||
'/api/public/keepalive': typeof ApiPublicKeepaliveRoute
|
||||
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
|
||||
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
|
||||
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
|
||||
@@ -224,10 +242,12 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
@@ -247,10 +267,12 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
@@ -270,10 +292,12 @@ export interface FileRouteTypes {
|
||||
| '/scout'
|
||||
| '/squadra'
|
||||
| '/allenamento/$id'
|
||||
| '/evento/$id'
|
||||
| '/partita-csi/$id'
|
||||
| '/partita/$id'
|
||||
| '/api/public/apri-sondaggio'
|
||||
| '/api/public/csi'
|
||||
| '/api/public/keepalive'
|
||||
| '/api/public/notifica-personalizzata'
|
||||
| '/api/public/notifiche-attive'
|
||||
| '/api/public/promemoria-palloni'
|
||||
@@ -294,10 +318,12 @@ export interface RootRouteChildren {
|
||||
ScoutRoute: typeof ScoutRoute
|
||||
SquadraRoute: typeof SquadraRoute
|
||||
AllenamentoIdRoute: typeof AllenamentoIdRoute
|
||||
EventoIdRoute: typeof EventoIdRoute
|
||||
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
|
||||
PartitaIdRoute: typeof PartitaIdRoute
|
||||
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
|
||||
ApiPublicCsiRoute: typeof ApiPublicCsiRoute
|
||||
ApiPublicKeepaliveRoute: typeof ApiPublicKeepaliveRoute
|
||||
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
|
||||
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
|
||||
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
|
||||
@@ -379,6 +405,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AllenamentoIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/evento/$id': {
|
||||
id: '/evento/$id'
|
||||
path: '/evento/$id'
|
||||
fullPath: '/evento/$id'
|
||||
preLoaderRoute: typeof EventoIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/partita-csi/$id': {
|
||||
id: '/partita-csi/$id'
|
||||
path: '/partita-csi/$id'
|
||||
@@ -407,6 +440,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiPublicCsiRouteImport
|
||||
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': {
|
||||
id: '/api/public/notifica-personalizzata'
|
||||
path: '/api/public/notifica-personalizzata'
|
||||
@@ -470,10 +510,12 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ScoutRoute: ScoutRoute,
|
||||
SquadraRoute: SquadraRoute,
|
||||
AllenamentoIdRoute: AllenamentoIdRoute,
|
||||
EventoIdRoute: EventoIdRoute,
|
||||
PartitaCsiIdRoute: PartitaCsiIdRoute,
|
||||
PartitaIdRoute: PartitaIdRoute,
|
||||
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
|
||||
ApiPublicCsiRoute: ApiPublicCsiRoute,
|
||||
ApiPublicKeepaliveRoute: ApiPublicKeepaliveRoute,
|
||||
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
|
||||
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
|
||||
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
|
||||
|
||||
@@ -91,7 +91,7 @@ function AllenamentoDetail() {
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
/**
|
||||
* Chiamata dal Vercel Cron Job (vedi vercel.json) per tenere attivo il progetto Supabase
|
||||
* free tier, che va in pausa dopo 7 giorni senza richieste API. Una lettura minima basta:
|
||||
* nessun dato sensibile, nessun effetto collaterale, quindi nessuna autenticazione richiesta.
|
||||
*/
|
||||
export const Route = createFileRoute("/api/public/keepalive")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { error } = await supabaseAdmin
|
||||
.from("giocatori_squadra")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.limit(1);
|
||||
if (error) {
|
||||
console.error("keepalive", error);
|
||||
return new Response("Errore", { status: 500 });
|
||||
}
|
||||
return new Response("ok");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { MapPin, Clock, Users, PartyPopper } from "lucide-react";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { useRosa } from "@/lib/rosa";
|
||||
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
|
||||
export const Route = createFileRoute("/evento/$id")({
|
||||
head: () => {
|
||||
const titolo = "Dettaglio evento";
|
||||
return {
|
||||
meta: [
|
||||
{ title: `${titolo} — CrAPP` },
|
||||
{
|
||||
name: "description",
|
||||
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:title", content: `${titolo} — CrAPP` },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
],
|
||||
};
|
||||
},
|
||||
component: EventoDetail,
|
||||
});
|
||||
|
||||
function EventoDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
const rosa = useRosa();
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
).length;
|
||||
|
||||
if (!evento) {
|
||||
return (
|
||||
<div className="px-5 pt-8">
|
||||
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Evento" sottotitolo={formatData(evento.data)} />
|
||||
|
||||
<Section titolo={evento.titolo}>
|
||||
<div className="rounded-3xl bg-card p-5 shadow-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-warning/15 text-warning">
|
||||
<PartyPopper className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Evento extra-campo
|
||||
</p>
|
||||
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" /> {evento.ora}
|
||||
</span>
|
||||
<span className="ml-4 inline-flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" /> {evento.luogo}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{evento.note ? (
|
||||
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
|
||||
{evento.note}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
|
||||
<Users className="h-4 w-4" />
|
||||
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -295,7 +295,7 @@ function PartitaDetail() {
|
||||
) : null}
|
||||
|
||||
<Section titolo="Rosa e presenze">
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} />
|
||||
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
+40
-24
@@ -130,31 +130,47 @@ function Squadra() {
|
||||
const contenutoRosa = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{allenatori.map((a) => (
|
||||
<article
|
||||
key={a.id}
|
||||
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<Avatar
|
||||
id={a.id}
|
||||
fallback={inizialiDa(nomeCompleto(a))}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{nomeCompleto(a)}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
||||
{a.nascita ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||
{allenatori.map((a) => {
|
||||
const isOpen = aperto === a.id;
|
||||
return (
|
||||
<article key={a.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperto(isOpen ? null : a.id)}
|
||||
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<Avatar
|
||||
id={a.id}
|
||||
fallback={inizialiDa(nomeCompleto(a))}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{nomeCompleto(a)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
||||
</span>
|
||||
</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) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = sonoAllenatore
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
contaPartiteGiocate,
|
||||
contaPresenzeGiocatore,
|
||||
destinatariSollecito,
|
||||
includeInfortunato,
|
||||
serieConferme,
|
||||
serieConsecutiva,
|
||||
totaliEventiGiocatore,
|
||||
@@ -295,4 +296,24 @@ const conAltri2 = conRisposta(
|
||||
assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache");
|
||||
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");
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/public/keepalive",
|
||||
"schedule": "0 5 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user