Semplifica Gestione eventi: solo calendario scorrevole e giorno già fissato nel form

Tolta la lista "Eventi in calendario", che ripeteva il calendario senza azioni proprie:
resta la griglia mensile, che ora si scorre anche con lo swipe come /calendario.
Caricamento ed errore compaiono dentro la card del calendario.

Nel form il giorno toccato sul calendario è mostrato come testo fisso e resta da
scegliere solo l'ora; il campo data compare con «Cambia», per spostare l'evento.

Documentazione: DD-033 (Gestione eventi) e DD-032 (stagione CSI ricavata dalle date),
indice delle decisioni completato con DD-030 e DD-031, rimandi dai moduli.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-24 14:10:11 +02:00
1 parent ae5d790011
commit e1e4ca0df6
5 files changed
+207 -87

No files matched your search

+4
View File
@@ -19,6 +19,10 @@ all'indietro.
elimina), toccando l'avatar si va al profilo come prima.
- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo
storico partite è diviso per stagione.
- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario
mensile, che ora si scorre anche con lo swipe come quello della squadra.
- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da
scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno).
## [0.9.2] - 2026-09-10
+81
View File
@@ -45,6 +45,10 @@ Serve a rispondere a domande del tipo:
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP |
| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato |
| [DD-030](#dd-030--centro-notifiche-in-app-storico-separato-dalla-push-letto-solo-con-rls) | Centro notifiche in-app |
| [DD-031](#dd-031--data-di-nascita-pubblica-una-colonna-sincronizzata-non-una-seconda-rls-aperta) | Nascita pubblica sincronizzata |
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
**In valutazione**
@@ -1318,3 +1322,80 @@ reso una funzione richiamabile: resta SQL diretto nella migration. La copertura
comportamento eseguendo lo stesso pattern SQL via `psql` nel container Postgres locale —
scelta necessaria perché senza una funzione RPC, PostgREST non espone un update
multi-tabella come questo.
### DD-032 — La stagione CSI si ricava dalle date delle gare
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
La pagina Campionato (`/classifica`) non diceva a quale stagione si riferissero classifica e
storico partite. Il portale CSI non espone il nome della stagione da nessuna parte:
`project-sheets.php`, `league_details.php`, `team-main.php` e il feed
`getEventsByTeamId.php` riportano solo il nome della competizione (verificato il
24/09/2026). Il `project_id` cambia a ogni stagione ed è aggiornato a mano in
`csi-core.ts`.
**Decisione**
`stagioneDi()` in `src/lib/csi-core.ts` ricava la stagione ("2025/26") dalla data di una
gara, con l'anno che cambia ad agosto; `stagioneDaPartite()` usa la prima gara del
campionato letto (tutte le gare, non solo quelle giocate, così a inizio stagione basta il
calendario). L'header di `/classifica` mostra quella stagione, e lo storico partite è
diviso per stagione con `raggruppaPerStagione()`: le gare dello Scout Live locale, usate
quando il CSI non ne ha, possono essere di stagioni diverse.
**Alternative scartate**
- Una costante `CSI_STAGIONE` accanto a `CSI_PROJECT_ID` → un secondo valore da ricordarsi
di aggiornare a ogni stagione, che può divergere dal `project_id`; ricavata dai dati
segue da sola.
- Usare la stagione "in corso" per la squadra (la data di oggi) → sbaglierebbe proprio nel
periodo di passaggio: a settembre 2026 il CSI mostra ancora i dati 2025/26.
**Conseguenze**
- Oggi Campionato mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la
stagione scritta nel codice, mostrano «Stagione 2026/27»: il CSI non ha ancora creato le
pagine della nuova stagione. È voluto — Campionato riporta la stagione dei dati che
mostra (dettagli in [collegamento-csi.md](modules/collegamento-csi.md)).
- Quando si aggiornano i `project_id` l'etichetta si aggiorna senza toccare altro codice.
**Riesame**
Se il CSI iniziasse a esporre il nome della stagione, leggerlo da lì; se una stagione CSI
dovesse iniziare prima di agosto, spostare la soglia in `stagioneDi()`.
### DD-033 — Gestione eventi: solo calendario, giorno fissato dal tocco
**Data:** 24 settembre 2026
**Stato:** Accettata
**Contesto**
`/eventi` mostrava sotto la griglia mensile anche la lista cronologica di tutti gli eventi
("Eventi in calendario"). La lista non aveva azioni proprie (toccare una riga apriva lo
stesso drawer del giorno) e allungava la pagina con informazioni già nel calendario. Nel
form, poi, il campo «Data» era già precompilato con il giorno toccato ma sembrava da
compilare: l'amministratore doveva scegliere solo l'ora.
**Decisione**
La pagina mostra solo il calendario, che si scorre di mese in mese con le frecce o con lo
swipe (stessa fisica a molla di `/calendario`, disattivata con `useMotoRidotto()`). Nel form
il giorno compare come testo fisso e resta da scegliere l'ora; il campo data compare solo
toccando «Cambia», per spostare un evento in un altro giorno.
**Alternative scartate**
- Togliere del tutto il campo data → non si potrebbe più spostare un evento esistente, una
funzione che oggi c'è (regola anti-regressione).
- Tenere la lista ma più corta (solo i prossimi eventi) → i prossimi eventi sono già in home
e in `/calendario`; qui sarebbe stata un doppione.
**Conseguenze**
- Per trovare un evento lontano bisogna scorrere fino al suo mese; il totale degli eventi
resta nel sottotitolo dell'header.
- Caricamento ed errore di `useEventi()` compaiono dentro la card del calendario, dove prima
stava la lista.
**Riesame**
Se gli eventi crescono tanto da rendere scomodo lo scorrere dei mesi, valutare una ricerca
per titolo invece di reintrodurre la lista completa.
+10 -8
View File
@@ -27,14 +27,16 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). Sopra alla lista
cronologica c'è una griglia mensile (stessa logica di `/calendario`, tramite le funzioni
condivise di `src/lib/calendario.ts`): ogni giorno è cliccabile, anche senza eventi, e apre
un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento in
questo giorno" che apre il form con la data già precompilata. Creare, modificare ed
eliminare passano solo da lì: la lista cronologica sotto il calendario è un elenco senza
azioni dirette, cliccare una riga apre lo stesso drawer del giorno corrispondente (anche se
è in un mese diverso da quello mostrato sulla griglia) invece di duplicare matita/cestino.
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
`src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con
la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e
apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento
in questo giorno" che apre il form con la data già precompilata: creare, modificare ed
eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da
scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista
cronologica di tutti gli eventi, tolta perché ripeteva il calendario
([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)).
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
+1 -1
View File
@@ -50,7 +50,7 @@ mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione s
codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione
dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica
la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola
a 2026/27.
a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)).
Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359`
(dietro `team_details.php`) contiene una sezione "Campionati" con un link
+111 -78
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits";
import {
@@ -28,6 +29,8 @@ import {
} from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
export const Route = createFileRoute("/eventi")({
head: () => ({
@@ -68,6 +71,7 @@ function GestioneEventi() {
const [bozza, setBozza] = useState<Evento | null>(null);
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const [confermaModifica, setConfermaModifica] = useState(false);
const [cambiaGiorno, setCambiaGiorno] = useState(false);
const rosa = squadra.filter((g) => g.attivo);
// SSR-safe: la data di oggi arriva solo dopo il mount.
@@ -77,7 +81,8 @@ function GestioneEventi() {
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<string | null>(null);
const { anno, mese, precedente, successivo } = useMeseNav();
const ridotto = useMotoRidotto();
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
if (!io || !admin) {
@@ -154,11 +159,13 @@ function GestioneEventi() {
function nuovoNelGiorno() {
if (!giornoSelezionato) return;
setBozza({ ...eventoVuoto(), data: giornoSelezionato });
setCambiaGiorno(false);
setGiornoSelezionato(null);
}
function modificaDalGiorno(e: Evento) {
setBozza(e);
setCambiaGiorno(false);
setGiornoSelezionato(null);
}
@@ -167,8 +174,6 @@ function GestioneEventi() {
setGiornoSelezionato(null);
}
// Non usa `eventiPerGiorno` (limitato al mese in vista sul calendario): il giorno
// selezionato può arrivare anche dalla lista sotto, per un mese diverso da quello mostrato.
const eventiGiornoSelezionato = giornoSelezionato
? eventi.filter((e) => e.data === giornoSelezionato)
: [];
@@ -206,34 +211,82 @@ function GestioneEventi() {
<span key={i}>{g}</span>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
return (
<button
key={giorno}
type="button"
onClick={() => apriGiorno(giorno)}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
haEventi ? "bg-accent text-accent-foreground" : "bg-secondary text-foreground",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
aria-current={isOggi ? "date" : undefined}
>
{giorno}
</button>
);
})}
{/* Swipe tra mesi con la stessa fisica di `/calendario` (vedi il commento lì). */}
<div className="relative -mx-1 mt-1 overflow-hidden p-1">
<AnimatePresence initial={false} mode="popLayout" custom={direzione}>
<motion.div
key={mesePrefix}
custom={direzione}
drag={ridotto ? false : "x"}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.18}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) successivo();
else if (arrivo > 60) precedente();
}}
initial={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * 48 }}
animate={{ opacity: 1, x: 0 }}
exit={ridotto ? { opacity: 0 } : { opacity: 0, x: direzione * -48 }}
transition={ridotto ? { duration: 0.2 } : molla.foglio}
className="grid touch-pan-y grid-cols-7 gap-1"
>
{Array.from({ length: offsetLunedi }).map((_, i) => (
<span key={`v${i}`} />
))}
{Array.from({ length: giorni }).map((_, i) => {
const giorno = i + 1;
const eventiGiorno = eventiPerGiorno.get(giorno) ?? [];
const haEventi = eventiGiorno.length > 0;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
return (
<button
key={giorno}
type="button"
onClick={() => apriGiorno(giorno)}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-sm font-semibold transition-transform active:scale-90",
haEventi
? "bg-accent text-accent-foreground"
: "bg-secondary text-foreground",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={`${giorno} ${mesiIT[mese]}${haEventi ? `: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}` : ", nessun evento"}`}
aria-current={isOggi ? "date" : undefined}
>
{giorno}
</button>
);
})}
</motion.div>
</AnimatePresence>
</div>
{isPending ? (
<p aria-busy="true" className="mt-2 text-xs text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="mt-3 space-y-2 text-center text-sm">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">
Scorri a destra o sinistra per cambiare mese. Tocca un giorno per aggiungere,
modificare o eliminare.
</p>
)}
</Card>
</Section>
@@ -270,14 +323,34 @@ function GestioneEventi() {
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data">
<input
type="date"
value={bozza.data}
onChange={(e) => aggiorna({ data: e.target.value })}
className={classiInput}
/>
</Campo>
{/* Il giorno arriva già dal calendario: si mostra e basta, il campo data
compare solo per spostare l'evento. */}
{cambiaGiorno ? (
<Campo label="Giorno">
<input
type="date"
value={bozza.data}
onChange={(e) => aggiorna({ data: e.target.value })}
className={classiInput}
/>
</Campo>
) : (
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Giorno
</span>
<div className="mt-1 flex h-10 items-center justify-between gap-1">
<span className="truncate text-sm font-semibold">{formatData(bozza.data)}</span>
<button
type="button"
onClick={() => setCambiaGiorno(true)}
className="shrink-0 rounded-full px-2 py-1 text-xs font-bold uppercase text-accent active:scale-95"
>
Cambia
</button>
</div>
</div>
)}
<Campo label="Ora">
<input
type="time"
@@ -393,46 +466,6 @@ function GestioneEventi() {
</Section>
) : null}
<Section titolo="Eventi in calendario">
{isPending ? (
<p aria-busy="true" className="text-center text-xs text-muted-foreground">
Carico gli eventi…
</p>
) : isError ? (
<div className="space-y-2 rounded-3xl bg-card p-4 text-center text-sm shadow-card">
<p className="text-destructive">
Non sono riuscito a caricare gli eventi
{error instanceof Error ? `: ${error.message}` : ""}.
</p>
<button
type="button"
onClick={() => refetch()}
className="rounded-2xl bg-secondary px-4 py-2 text-xs font-bold uppercase"
>
Riprova
</button>
</div>
) : (
<div className="space-y-2">
{eventi.map((e) => (
<button
key={e.id}
type="button"
onClick={() => setGiornoSelezionato(e.data)}
className="flex w-full items-center gap-2 rounded-3xl bg-card p-3 text-left shadow-card active:scale-[0.99]"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
<p className="text-xs text-muted-foreground">
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
</p>
</div>
</button>
))}
</div>
)}
</Section>
<Drawer
open={!!giornoSelezionato}
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}