From 031456088510b549e2d50e55690776feb82aed5b Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Mon, 14 Sep 2026 08:57:24 +0200 Subject: [PATCH] Sostituisce la lista di Gestione eventi con un calendario mensile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In /eventi, sopra la lista cronologica c'è ora una griglia mensile (condivisa con /calendario tramite le nuove funzioni pure di src/lib/calendario.ts): ogni giorno è cliccabile e apre un drawer con gli eventi di quel giorno, da cui si crea, modifica o elimina un evento. Ogni azione resta raggiungibile in un solo modo: rimosso il bottone "Nuovo evento" e le icone matita/cestino della lista sotto, che ora si limita a mostrare gli eventi e ad aprire lo stesso drawer del giorno al click su una riga. Il campo "Luogo" di un nuovo evento parte vuoto invece che precompilato con "Palestra Comunale". Co-Authored-By: Claude Sonnet 5 --- docs/modules/calendario.md | 18 ++- src/lib/calendario.ts | 63 +++++++++++ src/lib/eventi.ts | 2 +- src/routes/calendario.tsx | 60 +--------- src/routes/eventi.tsx | 207 +++++++++++++++++++++++++++++------ test/unit/calendario.test.ts | 24 ++++ 6 files changed, 277 insertions(+), 97 deletions(-) create mode 100644 src/lib/calendario.ts create mode 100644 test/unit/calendario.test.ts diff --git a/docs/modules/calendario.md b/docs/modules/calendario.md index dc23963..ccf242e 100644 --- a/docs/modules/calendario.md +++ b/docs/modules/calendario.md @@ -1,10 +1,11 @@ # Modulo — Calendario ed Eventi **Stato:** implementato -**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx` -(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin), -`src/components/crapp/EventoCard.tsx` (card condivisa) -**Test:** `test/unit/eventi.test.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), +`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx` +(card condivisa) +**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts` --- @@ -26,7 +27,14 @@ 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). + 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. Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate; diff --git a/src/lib/calendario.ts b/src/lib/calendario.ts new file mode 100644 index 0000000..c6ec8f7 --- /dev/null +++ b/src/lib/calendario.ts @@ -0,0 +1,63 @@ +import { useState } from "react"; + +/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */ +export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"]; + +export const mesiIT = [ + "Gennaio", + "Febbraio", + "Marzo", + "Aprile", + "Maggio", + "Giugno", + "Luglio", + "Agosto", + "Settembre", + "Ottobre", + "Novembre", + "Dicembre", +] as const; + +export function pad2(n: number) { + return String(n).padStart(2, "0"); +} + +/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */ +export function giorniDelMese(anno: number, mese: number) { + const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate(); + const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay(); + const offsetLunedi = (primoGiorno + 6) % 7; + return { giorni, offsetLunedi }; +} + +/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */ +export function useMeseNav(initial?: { anno: number; mese: number }) { + const oggi = new Date(); + const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear()); + const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth()); + // Serve a far entrare e uscire la griglia dallo stesso lato del gesto: + // se un mese esce a sinistra, il precedente deve rientrare da sinistra. + const [direzione, setDirezione] = useState(0); + + const precedente = () => { + setDirezione(-1); + if (mese === 0) { + setMese(11); + setAnno((a) => a - 1); + } else { + setMese((m) => m - 1); + } + }; + + const successivo = () => { + setDirezione(1); + if (mese === 11) { + setMese(0); + setAnno((a) => a + 1); + } else { + setMese((m) => m + 1); + } + }; + + return { anno, mese, direzione, precedente, successivo }; +} diff --git a/src/lib/eventi.ts b/src/lib/eventi.ts index cfe3ba9..a68d851 100644 --- a/src/lib/eventi.ts +++ b/src/lib/eventi.ts @@ -104,7 +104,7 @@ export function eventoVuoto(): Evento { id: nuovoIdEvento(), tipo: "allenamento", titolo: "", - luogo: "Palestra Comunale", + luogo: "", data: new Date().toISOString().slice(0, 10), ora: "20:30", note: "", diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index fe65169..effa329 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -11,6 +11,7 @@ import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { useAnagraficaRosa } from "@/lib/rosa"; import { useIsAdmin } from "@/lib/ruoli"; +import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { Drawer, DrawerClose, @@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({ component: Calendario, }); -const giorniIT = ["L", "M", "M", "G", "V", "S", "D"]; - /** Colore per tipo di evento, usato per dividere le celle con più tipi. */ const coloreTipo: Record = { partita: "var(--accent)", @@ -47,63 +46,6 @@ const coloreTipo: Record = { compleanno: "var(--success)", }; -const mesiIT = [ - "Gennaio", - "Febbraio", - "Marzo", - "Aprile", - "Maggio", - "Giugno", - "Luglio", - "Agosto", - "Settembre", - "Ottobre", - "Novembre", - "Dicembre", -] as const; - -function useMeseNav(initial?: { anno: number; mese: number }) { - const oggi = new Date(); - const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear()); - const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth()); - // Serve a far entrare e uscire la griglia dallo stesso lato del gesto: - // se un mese esce a sinistra, il precedente deve rientrare da sinistra. - const [direzione, setDirezione] = useState(0); - - const precedente = () => { - setDirezione(-1); - if (mese === 0) { - setMese(11); - setAnno((a) => a - 1); - } else { - setMese((m) => m - 1); - } - }; - - const successivo = () => { - setDirezione(1); - if (mese === 11) { - setMese(0); - setAnno((a) => a + 1); - } else { - setMese((m) => m + 1); - } - }; - - return { anno, mese, direzione, precedente, successivo }; -} - -function giorniDelMese(anno: number, mese: number) { - const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate(); - const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay(); - const offsetLunedi = (primoGiorno + 6) % 7; - return { giorni, offsetLunedi }; -} - -function pad2(n: number) { - return String(n).padStart(2, "0"); -} - function Calendario() { // SSR-safe: la data di oggi arriva solo dopo il mount. const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null); diff --git a/src/routes/eventi.tsx b/src/routes/eventi.tsx index 34b99ea..66fbb32 100644 --- a/src/routes/eventi.tsx +++ b/src/routes/eventi.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; +import { CalendarPlus, ChevronLeft, ChevronRight, Loader2, Pencil, Trash2, X } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { Campo, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits"; +import { Campo, Card, classiInput, PageHeader, Section } from "@/components/crapp/ui-bits"; import { Drawer, DrawerClose, @@ -14,6 +14,7 @@ import { DrawerTitle, } from "@/components/ui/drawer"; import { formatData } from "@/lib/crapp-data"; +import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { categoriaEvento, @@ -69,6 +70,16 @@ function GestioneEventi() { const [confermaModifica, setConfermaModifica] = useState(false); const rosa = squadra.filter((g) => g.attivo); + // SSR-safe: la data di oggi arriva solo dopo il mount. + const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null); + useEffect(() => { + const d = new Date(); + setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() }); + }, []); + const [giornoSelezionato, setGiornoSelezionato] = useState(null); + const { anno, mese, precedente, successivo } = useMeseNav(); + const { giorni, offsetLunedi } = giorniDelMese(anno, mese); + if (!io || !admin) { return ( <> @@ -126,19 +137,105 @@ function GestioneEventi() { } } + const mesePrefix = `${anno}-${pad2(mese + 1)}`; + const eventiPerGiorno = new Map(); + for (const e of eventi) { + if (!e.data.startsWith(mesePrefix)) continue; + const g = Number(e.data.slice(8, 10)); + const lista = eventiPerGiorno.get(g) ?? []; + lista.push(e); + eventiPerGiorno.set(g, lista); + } + + function apriGiorno(giorno: number) { + setGiornoSelezionato(`${mesePrefix}-${pad2(giorno)}`); + } + + function nuovoNelGiorno() { + if (!giornoSelezionato) return; + setBozza({ ...eventoVuoto(), data: giornoSelezionato }); + setGiornoSelezionato(null); + } + + function modificaDalGiorno(e: Evento) { + setBozza(e); + setGiornoSelezionato(null); + } + + function eliminaDalGiorno(e: Evento) { + setDaEliminare(e); + 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) + : []; + return ( <> -
- -
+
+ +
+ + + {mesiIT[mese]} {anno} + + +
+ +
+ {giorniIT.map((g, i) => ( + {g} + ))} +
+
+ {Array.from({ length: offsetLunedi }).map((_, 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 ( + + ); + })} +
+
+
{bozza ? (
aggiorna({ titolo: e.target.value })} - placeholder="Es. CRAP Volley vs Aurora Nera" className={classiInput} /> @@ -319,9 +415,11 @@ function GestioneEventi() { ) : (
{eventi.map((e) => ( -
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]" >

{e.titolo}

@@ -329,28 +427,73 @@ function GestioneEventi() { {formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}

- - -
+ ))}
)}
+ !aperto && setGiornoSelezionato(null)} + > + + + + {giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"} + + + + Chiudi + + +
+ {eventiGiornoSelezionato.length > 0 ? ( + eventiGiornoSelezionato.map((e) => ( +
+
+

{e.titolo}

+

+ {e.ora} · {e.luogo || "luogo da definire"} +

+
+ + +
+ )) + ) : ( +

+ Nessun evento in programma +

+ )} +
+ +
+
+ !aperto && setConfermaModifica(false)} diff --git a/test/unit/calendario.test.ts b/test/unit/calendario.test.ts new file mode 100644 index 0000000..a06e938 --- /dev/null +++ b/test/unit/calendario.test.ts @@ -0,0 +1,24 @@ +/** Check delle funzioni pure della griglia mensile: `bun test/unit/calendario.test.ts`. */ +import assert from "node:assert/strict"; +import { giorniDelMese, pad2 } from "@/lib/calendario"; + +// --- pad2 -------------------------------------------------------------- +assert.equal(pad2(1), "01"); +assert.equal(pad2(9), "09"); +assert.equal(pad2(10), "10"); +assert.equal(pad2(31), "31"); + +// --- giorniDelMese ------------------------------------------------------- +// Febbraio 2026 (non bisestile): 28 giorni, inizia di domenica -> offset 6. +assert.deepEqual(giorniDelMese(2026, 1), { giorni: 28, offsetLunedi: 6 }); + +// Febbraio 2028 (bisestile): 29 giorni. +assert.deepEqual(giorniDelMese(2028, 1), { giorni: 29, offsetLunedi: 1 }); + +// Settembre 2026: 30 giorni, il 1° settembre 2026 è martedì -> offset 1. +assert.deepEqual(giorniDelMese(2026, 8), { giorni: 30, offsetLunedi: 1 }); + +// Gennaio 2027: il 1° gennaio è venerdì -> offset 4. +assert.deepEqual(giorniDelMese(2027, 0), { giorni: 31, offsetLunedi: 4 }); + +console.log("calendario: ok");