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>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-28 11:01:46 +02:00
1 parent a72eb087d1
commit 39918f2eb7
13 files changed
+220 -41

No files matched your search

+5
View File
@@ -20,6 +20,11 @@ versioni `0.y.z` erano pre-release.
- **Keepalive giornaliero** — un Vercel Cron Job chiama `/api/public/keepalive` una volta - **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 al giorno con una lettura minima, per evitare che il progetto Supabase free tier vada in
pausa dopo 7 giorni senza richieste API. 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 ### Modificato
+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`)
+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 (
+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
+21
View File
@@ -19,6 +19,7 @@ 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'
@@ -82,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',
@@ -158,6 +164,7 @@ 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
@@ -182,6 +189,7 @@ 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
@@ -207,6 +215,7 @@ 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
@@ -233,6 +242,7 @@ 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'
@@ -257,6 +267,7 @@ 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'
@@ -281,6 +292,7 @@ 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'
@@ -306,6 +318,7 @@ 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
@@ -392,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'
@@ -490,6 +510,7 @@ 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,
+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>
</> </>
); );
+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>
</> </>
); );
+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");