3 Commits
Author SHA1 Message Date
davideandClaude Sonnet 5 0314560885 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>
2026-09-14 08:57:24 +02:00
davideandClaude Sonnet 5 0286961e92 Aggiunge la possibilità di inviare notifiche push personalizzate dall'admin
Nella tab Notifiche della dashboard admin è ora possibile mostrare anche i
giocatori senza notifiche attive e inviare un messaggio libero a tutta la
squadra o a un singolo giocatore, tramite un nuovo endpoint
/api/public/notifica-personalizzata che riusa lo stesso pattern di invio
delle altre route push admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 08:05:15 +02:00
davideandClaude Sonnet 5 6372ef9c8d Aggiunge la conferma di modifica in Gestione eventi
Prima di salvare l'aggiornamento di un evento esistente, mostra un
drawer di conferma coerente con quello già usato per l'eliminazione.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 07:50:54 +02:00
13 changed files with 582 additions and 108 deletions
+13 -5
View File
@@ -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;
+11 -1
View File
@@ -83,12 +83,22 @@ variabile d'ambiente.
| Route | Controllo | Chi la chiama | | Route | Controllo | Chi la chiama |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | | `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin |
| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | | `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora |
`notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno `notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno
un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche" un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche"
della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo. della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo.
La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate:
l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non
l'ha ancora attivata.
`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId`
lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a
quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto
dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione
che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard
admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore.
--- ---
+63
View File
@@ -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
View File
@@ -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: "",
+16 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { intestazioniAutenticate } from "./auth"; import { intestazioniAutenticate } from "./auth";
const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const; const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const;
@@ -20,3 +20,18 @@ export function useNotificheAttive() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
} }
/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */
export function useInviaNotifica() {
return useMutation({
mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => {
const res = await fetch("/api/public/notifica-personalizzata", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ messaggio, giocatoreId }),
});
if (!res.ok) throw new Error("Impossibile inviare la notifica");
return (await res.json()) as { inviate: number; destinatari: number };
},
});
}
+22
View File
@@ -23,6 +23,7 @@ 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'
import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi'
import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata'
import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive' import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive'
import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni'
import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config'
@@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({
path: '/api/public/csi', path: '/api/public/csi',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiPublicNotificaPersonalizzataRoute =
ApiPublicNotificaPersonalizzataRouteImport.update({
id: '/api/public/notifica-personalizzata',
path: '/api/public/notifica-personalizzata',
getParentRoute: () => rootRouteImport,
} as any)
const ApiPublicNotificheAttiveRoute = const ApiPublicNotificheAttiveRoute =
ApiPublicNotificheAttiveRouteImport.update({ ApiPublicNotificheAttiveRouteImport.update({
id: '/api/public/notifiche-attive', id: '/api/public/notifiche-attive',
@@ -149,6 +156,7 @@ export interface FileRoutesByFullPath {
'/partita/$id': typeof PartitaIdRoute '/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute '/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -171,6 +179,7 @@ export interface FileRoutesByTo {
'/partita/$id': typeof PartitaIdRoute '/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute '/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -194,6 +203,7 @@ export interface FileRoutesById {
'/partita/$id': typeof PartitaIdRoute '/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
'/api/public/csi': typeof ApiPublicCsiRoute '/api/public/csi': typeof ApiPublicCsiRoute
'/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute
'/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute
'/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute
'/api/public/push-config': typeof ApiPublicPushConfigRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute
@@ -218,6 +228,7 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -240,6 +251,7 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -262,6 +274,7 @@ export interface FileRouteTypes {
| '/partita/$id' | '/partita/$id'
| '/api/public/apri-sondaggio' | '/api/public/apri-sondaggio'
| '/api/public/csi' | '/api/public/csi'
| '/api/public/notifica-personalizzata'
| '/api/public/notifiche-attive' | '/api/public/notifiche-attive'
| '/api/public/promemoria-palloni' | '/api/public/promemoria-palloni'
| '/api/public/push-config' | '/api/public/push-config'
@@ -285,6 +298,7 @@ export interface RootRouteChildren {
PartitaIdRoute: typeof PartitaIdRoute PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
ApiPublicCsiRoute: typeof ApiPublicCsiRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute
ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute
ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute
ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute
ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute
@@ -393,6 +407,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiPublicCsiRouteImport preLoaderRoute: typeof ApiPublicCsiRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/public/notifica-personalizzata': {
id: '/api/public/notifica-personalizzata'
path: '/api/public/notifica-personalizzata'
fullPath: '/api/public/notifica-personalizzata'
preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport
parentRoute: typeof rootRouteImport
}
'/api/public/notifiche-attive': { '/api/public/notifiche-attive': {
id: '/api/public/notifiche-attive' id: '/api/public/notifiche-attive'
path: '/api/public/notifiche-attive' path: '/api/public/notifiche-attive'
@@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = {
PartitaIdRoute: PartitaIdRoute, PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
ApiPublicCsiRoute: ApiPublicCsiRoute, ApiPublicCsiRoute: ApiPublicCsiRoute,
ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute,
ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute, ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute,
ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute,
ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute,
+123 -7
View File
@@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { import {
BadgeCheck, BadgeCheck,
Bell, Bell,
BellOff,
ChevronDown, ChevronDown,
Download, Download,
FileText, FileText,
@@ -10,6 +11,7 @@ import {
Image, Image,
Loader2, Loader2,
Lock, Lock,
Send,
Unlink, Unlink,
UserCheck, UserCheck,
UserPlus, UserPlus,
@@ -49,8 +51,17 @@ import {
import { oggiISO } from "@/lib/palloni-core"; import { oggiISO } from "@/lib/palloni-core";
import { scaricaCsv } from "@/lib/scout-export"; import { scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useNotificheAttive } from "@/lib/notifiche-admin"; import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/admin")({ export const Route = createFileRoute("/admin")({
head: () => ({ head: () => ({
@@ -621,6 +632,32 @@ function Dashboard() {
const { data: notificheAttive } = useNotificheAttive(); const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null); const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO(); const oggi = oggiISO();
const inviaNotifica = useInviaNotifica();
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
id?: string;
nome: string;
} | null>(null);
const [messaggio, setMessaggio] = useState("");
async function inviaMessaggio() {
if (!destinatarioMessaggio || !messaggio.trim()) return;
try {
const dati = await inviaNotifica.mutateAsync(
destinatarioMessaggio.id
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
: { messaggio: messaggio.trim() },
);
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con notifiche attive per l'invio",
);
setDestinatarioMessaggio(null);
setMessaggio("");
} catch {
toast.error("Non sono riuscito a inviare la notifica");
}
}
if (!admin) { if (!admin) {
return ( return (
@@ -703,15 +740,42 @@ function Dashboard() {
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`} valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
label="Notifiche attive" label="Notifiche attive"
/> />
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 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"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{attivi {attivi.map((g) => {
.filter((g) => notificheAttive.has(g.id)) const attiva = notificheAttive.has(g.id);
.map((g) => ( return (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"> <div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
<Bell className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> {attiva ? (
<p className="truncate text-sm font-semibold leading-tight">{nomeCompleto(g)}</p> <Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
{nomeCompleto(g)}
</p>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
>
<Send className="h-3.5 w-3.5" />
</button>
</div> </div>
))} );
})}
</div> </div>
</> </>
) : ( ) : (
@@ -737,6 +801,58 @@ function Dashboard() {
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche }, { id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]} ]}
/> />
<Drawer
open={!!destinatarioMessaggio}
onOpenChange={(aperto) => {
if (!aperto) {
setDestinatarioMessaggio(null);
setMessaggio("");
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Invia messaggio</DrawerTitle>
<DrawerDescription>
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<textarea
value={messaggio}
maxLength={300}
rows={3}
onChange={(e) => setMessaggio(e.target.value)}
placeholder="Scrivi il messaggio da inviare come notifica push…"
className={cn(classiInput, "h-auto")}
/>
</div>
<DrawerFooter>
<button
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="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 disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}{" "}
Invia
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</> </>
); );
} }
@@ -0,0 +1,53 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { inviaPush } from "@/lib/webpush.server";
const schema = z.object({
messaggio: z.string().min(1).max(300),
giocatoreId: z.string().min(1).max(50).optional(),
});
export const Route = createFileRoute("/api/public/notifica-personalizzata")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
if (!parsed.success) return new Response("Dati non validi", { status: 400 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
let query = supabaseAdmin.from("push_subscriptions").select("endpoint, p256dh, auth");
if (parsed.data.giocatoreId) {
query = query.eq("giocatore_id", parsed.data.giocatoreId);
}
const { data: iscrizioni } = await query;
const titolo = "Messaggio dallo staff";
const testo = parsed.data.messaggio.trim();
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
await supabaseAdmin
.from("push_subscriptions")
.delete()
.eq("endpoint", iscrizione.endpoint);
} else if (stato >= 200 && stato < 300) {
inviate += 1;
}
} catch (error) {
console.error("notifica-personalizzata", error);
}
}
return Response.json({ inviate, destinatari: iscrizioni?.length ?? 0 });
},
},
},
});
+1 -59
View File
@@ -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);
+223 -34
View File
@@ -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,
@@ -66,8 +67,19 @@ function GestioneEventi() {
const elimina = useEliminaEvento(); const elimina = useEliminaEvento();
const [bozza, setBozza] = useState<Evento | null>(null); const [bozza, setBozza] = useState<Evento | null>(null);
const [daEliminare, setDaEliminare] = useState<Evento | null>(null); const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
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 (
<> <>
@@ -85,18 +97,31 @@ function GestioneEventi() {
setBozza((b) => (b ? { ...b, ...patch } : b)); setBozza((b) => (b ? { ...b, ...patch } : b));
} }
async function conferma() { const modificaEsistente = bozza ? eventi.some((e) => e.id === bozza.id) : false;
function chiediConferma() {
if (!bozza) return; if (!bozza) return;
if (!bozza.titolo.trim()) { if (!bozza.titolo.trim()) {
toast.error("Serve un titolo per l'evento"); toast.error("Serve un titolo per l'evento");
return; return;
} }
if (modificaEsistente) {
setConfermaModifica(true);
return;
}
conferma();
}
async function conferma() {
if (!bozza) return;
try { try {
await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() }); await salva.mutateAsync({ ...bozza, titolo: bozza.titolo.trim() });
toast.success("Evento salvato"); toast.success("Evento salvato");
setBozza(null); setBozza(null);
} catch { } catch {
toast.error("Non sono riuscito a salvare l'evento"); toast.error("Non sono riuscito a salvare l'evento");
} finally {
setConfermaModifica(false);
} }
} }
@@ -112,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
@@ -154,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>
@@ -272,7 +382,7 @@ function GestioneEventi() {
</button> </button>
<button <button
type="button" type="button"
onClick={conferma} onClick={chiediConferma}
disabled={salva.isPending} disabled={salva.isPending}
className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50" className="flex flex-1 items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
> >
@@ -305,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>
@@ -315,28 +427,105 @@ 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
open={confermaModifica}
onOpenChange={(aperto) => !aperto && setConfermaModifica(false)}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Salvare le modifiche?</DrawerTitle>
<DrawerDescription>
{bozza ? `Aggiorni l'evento "${bozza.titolo.trim() || "senza titolo"}".` : ""}
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<button
type="button"
onClick={conferma}
disabled={salva.isPending}
className="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 disabled:opacity-50"
>
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null} Conferma
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-sm font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}> <Drawer open={!!daEliminare} onOpenChange={(aperto) => !aperto && setDaEliminare(null)}>
<DrawerContent> <DrawerContent>
<DrawerHeader> <DrawerHeader>
+7
View File
@@ -230,6 +230,13 @@ try {
} }
}); });
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
assert.equal(
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
401,
);
});
await prova("un token malformato non passa", async () => { await prova("un token malformato non passa", async () => {
const res = await fetch(url("/api/public/sollecita-presenze"), { const res = await fetch(url("/api/public/sollecita-presenze"), {
method: "POST", method: "POST",
+25
View File
@@ -107,6 +107,31 @@ if (!locale) {
assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`); assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`);
} }
}); });
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
method: "POST",
headers: { "content-type": "application/json", ...intestazioni },
body: JSON.stringify({ messaggio: "prova" }),
});
await prova("senza token non si manda un messaggio personalizzato", async () => {
assert.equal((await chiamaMessaggio()).status, 401);
});
await prova("un giocatore autenticato non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenGiocatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
const corpo = (await res.json()) as { inviate: number; destinatari: number };
assert.equal(corpo.destinatari, 0);
assert.equal(corpo.inviate, 0);
});
} finally { } finally {
server.stop(); server.stop();
for (const id of idUtenti) { for (const id of idUtenti) {
+24
View File
@@ -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");