Sostituisce la lista di Gestione eventi con un calendario mensile
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
# Modulo — Calendario ed Eventi
|
# Modulo — Calendario ed Eventi
|
||||||
|
|
||||||
**Stato:** implementato
|
**Stato:** implementato
|
||||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx`
|
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||||
(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin),
|
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||||
`src/components/crapp/EventoCard.tsx` (card condivisa)
|
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
|
||||||
**Test:** `test/unit/eventi.test.ts`
|
(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
|
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
(`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
|
Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il
|
||||||
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+1
-1
@@ -104,7 +104,7 @@ export function eventoVuoto(): Evento {
|
|||||||
id: nuovoIdEvento(),
|
id: nuovoIdEvento(),
|
||||||
tipo: "allenamento",
|
tipo: "allenamento",
|
||||||
titolo: "",
|
titolo: "",
|
||||||
luogo: "Palestra Comunale",
|
luogo: "",
|
||||||
data: new Date().toISOString().slice(0, 10),
|
data: new Date().toISOString().slice(0, 10),
|
||||||
ora: "20:30",
|
ora: "20:30",
|
||||||
note: "",
|
note: "",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
|||||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||||
import { useIsAdmin } from "@/lib/ruoli";
|
import { useIsAdmin } from "@/lib/ruoli";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -37,8 +38,6 @@ export const Route = createFileRoute("/calendario")({
|
|||||||
component: 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. */
|
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
|
||||||
const coloreTipo: Record<Evento["tipo"], string> = {
|
const coloreTipo: Record<Evento["tipo"], string> = {
|
||||||
partita: "var(--accent)",
|
partita: "var(--accent)",
|
||||||
@@ -47,63 +46,6 @@ const coloreTipo: Record<Evento["tipo"], string> = {
|
|||||||
compleanno: "var(--success)",
|
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() {
|
function Calendario() {
|
||||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||||
|
|||||||
+175
-32
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
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 { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
DrawerTitle,
|
DrawerTitle,
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { formatData } from "@/lib/crapp-data";
|
import { formatData } from "@/lib/crapp-data";
|
||||||
|
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||||
import {
|
import {
|
||||||
categoriaEvento,
|
categoriaEvento,
|
||||||
@@ -69,6 +70,16 @@ function GestioneEventi() {
|
|||||||
const [confermaModifica, setConfermaModifica] = useState(false);
|
const [confermaModifica, setConfermaModifica] = useState(false);
|
||||||
const rosa = squadra.filter((g) => g.attivo);
|
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<string | null>(null);
|
||||||
|
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||||
|
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||||
|
|
||||||
if (!io || !admin) {
|
if (!io || !admin) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -126,19 +137,105 @@ function GestioneEventi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
|
||||||
|
const eventiPerGiorno = new Map<number, Evento[]>();
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
<PageHeader titolo="Gestione eventi" sottotitolo={`${eventi.length} eventi in calendario`} />
|
||||||
|
|
||||||
<div className="px-5 pt-4">
|
<Section titolo="Calendario">
|
||||||
<button
|
<Card>
|
||||||
type="button"
|
<div className="mb-3 flex items-center justify-between">
|
||||||
onClick={() => setBozza(eventoVuoto())}
|
<button
|
||||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
type="button"
|
||||||
>
|
onClick={precedente}
|
||||||
<CalendarPlus className="h-4 w-4" /> Nuovo evento
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
</button>
|
aria-label="Mese precedente"
|
||||||
</div>
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-display-sm text-xl uppercase">
|
||||||
|
{mesiIT[mese]} {anno}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={successivo}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label="Mese successivo"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-bold text-muted-foreground">
|
||||||
|
{giorniIT.map((g, i) => (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
{bozza ? (
|
{bozza ? (
|
||||||
<Section
|
<Section
|
||||||
@@ -168,7 +265,6 @@ function GestioneEventi() {
|
|||||||
value={bozza.titolo}
|
value={bozza.titolo}
|
||||||
maxLength={80}
|
maxLength={80}
|
||||||
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
onChange={(e) => aggiorna({ titolo: e.target.value })}
|
||||||
placeholder="Es. CRAP Volley vs Aurora Nera"
|
|
||||||
className={classiInput}
|
className={classiInput}
|
||||||
/>
|
/>
|
||||||
</Campo>
|
</Campo>
|
||||||
@@ -319,9 +415,11 @@ function GestioneEventi() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{eventi.map((e) => (
|
{eventi.map((e) => (
|
||||||
<div
|
<button
|
||||||
key={e.id}
|
key={e.id}
|
||||||
className="flex items-center gap-2 rounded-3xl bg-card p-3 shadow-card"
|
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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
<p className="truncate text-sm font-bold leading-tight">{e.titolo}</p>
|
||||||
@@ -329,28 +427,73 @@ function GestioneEventi() {
|
|||||||
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
{formatData(e.data)} · {e.ora} · {e.luogo || "luogo da definire"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
onClick={() => setBozza(e)}
|
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
|
||||||
aria-label={`Modifica ${e.titolo}`}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDaEliminare(e)}
|
|
||||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
|
||||||
aria-label={`Elimina ${e.titolo}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!giornoSelezionato}
|
||||||
|
onOpenChange={(aperto) => !aperto && setGiornoSelezionato(null)}
|
||||||
|
>
|
||||||
|
<DrawerContent className="rounded-t-[24px] border-border bg-background px-4 pb-6 pt-2">
|
||||||
|
<DrawerHeader className="relative px-0 pb-2 text-left">
|
||||||
|
<DrawerTitle className="font-display-lg text-2xl uppercase">
|
||||||
|
{giornoSelezionato ? formatData(giornoSelezionato) : "Giorno"}
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerClose className="absolute right-0 top-0 grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground transition-transform active:scale-90">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Chiudi</span>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="max-h-[50vh] space-y-2 overflow-y-auto py-2">
|
||||||
|
{eventiGiornoSelezionato.length > 0 ? (
|
||||||
|
eventiGiornoSelezionato.map((e) => (
|
||||||
|
<div
|
||||||
|
key={e.id}
|
||||||
|
className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{e.ora} · {e.luogo || "luogo da definire"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => modificaDalGiorno(e)}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
|
||||||
|
aria-label={`Modifica ${e.titolo}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => eliminaDalGiorno(e)}
|
||||||
|
className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-destructive/10 text-destructive active:scale-95"
|
||||||
|
aria-label={`Elimina ${e.titolo}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||||
|
Nessun evento in programma
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nuovoNelGiorno}
|
||||||
|
className="premi mt-1 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" /> Nuovo evento in questo giorno
|
||||||
|
</button>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
open={confermaModifica}
|
open={confermaModifica}
|
||||||
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
|
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
|
||||||
|
|||||||
@@ -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");
|
||||||
Reference in New Issue
Block a user