Files
CRAPP/src/routes/calendario.tsx
T
davideandClaude Sonnet 5.5 5ca081a6af Eventi visibili solo ai convocati; «Rosa e presenze» filtrata sui convocati
- «Rosa e presenze» (allenamento, partita, evento) elenca, conta e solleciti solo i convocati
  invece di tutta la rosa; il riquadro «La tua risposta» sparisce per i non convocati.
- Calendario, Home e elenco eventi mostrano un evento con convocati solo a chi è convocato;
  admin e allenatori lo vedono sempre, `convocati` vuoto = tutti (eventoVisibile).
- Filtro solo lato interfaccia: la RLS di eventi_app non cambia.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:12:02 +02:00

323 lines
14 KiB
TypeScript

import { useEffect, useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { CalendarPlus, ChevronLeft, ChevronRight, X } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, type Evento } from "@/lib/eventi";
import { useEventiVisibili } from "@/lib/eventi-visibili";
import { useAnagraficaRosa } from "@/lib/rosa";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/calendario")({
head: () => ({
meta: [
{ title: "Calendario squadra — CrAPP" },
{
name: "description",
content: "Allenamenti, partite ed eventi extra del CRAP Volley con vista mensile.",
},
{ property: "og:title", content: "Calendario squadra — CrAPP" },
{
property: "og:description",
content: "Vista mensile e prossimi eventi per la squadra.",
},
],
}),
component: Calendario,
});
/** Colore per tipo di evento, usato per dividere le celle con più tipi. */
const coloreTipo: Record<Evento["tipo"], string> = {
partita: "var(--accent)",
allenamento: "var(--training)",
evento: "var(--warning)",
compleanno: "var(--success)",
};
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);
useEffect(() => {
const d = new Date();
setOggi({ anno: d.getFullYear(), mese: d.getMonth(), giorno: d.getDate() });
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const puoGestire = usePuoGestireEventi();
const { eventi } = useEventiVisibili();
const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto();
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
const mesePrefix = `${anno}-${pad2(mese + 1)}`;
const compleanni = compleanniEventi(rosa, anno);
const compleanniMese = compleanni.filter((c) => c.data.startsWith(mesePrefix));
const eventiMese = eventi.filter((e) => e.data.startsWith(mesePrefix));
const eventiPerGiorno = new Map<number, Evento[]>();
for (const e of [...compleanniMese, ...eventiMese]) {
const g = Number(e.data.slice(8, 10));
const lista = eventiPerGiorno.get(g) ?? [];
lista.push(e);
eventiPerGiorno.set(g, lista);
}
function apriGiorno(giorno: number) {
if (!eventiPerGiorno.has(giorno)) return;
setGiornoSelezionato(giorno);
setDrawerAperto(true);
}
const eventiGiornoSelezionato = giornoSelezionato
? (eventiPerGiorno.get(giornoSelezionato) ?? [])
: [];
// Prossimi 3 eventi da oggi in avanti (indipendenti dal mese selezionato nella griglia).
const oggiIso = oggi ? `${oggi.anno}-${pad2(oggi.mese + 1)}-${pad2(oggi.giorno)}` : null;
const prossimiEventi = oggiIso ? eventi.filter((e) => e.data >= oggiIso).slice(0, 3) : [];
// Solo i compleanni del mese in corso non ancora passati (oggi compreso), a prescindere dal mese
// mostrato nella griglia.
const compleanniMeseCorrente =
oggi && oggiIso
? compleanniEventi(rosa, oggi.anno).filter((c) => c.data.startsWith(oggiIso.slice(0, 7)))
: [];
const prossimiCompleanni = oggiIso ? compleanniMeseCorrente.filter((c) => c.data >= oggiIso) : [];
return (
<>
<PageHeader titolo="Calendario" sottotitolo={`${mesiIT[mese]} ${anno} · Stagione 2026/27`} />
<Section>
<Card>
<div className="mb-3 flex items-center justify-between">
<button
type="button"
onClick={precedente}
className="grid h-11 w-11 place-items-center rounded-full bg-secondary text-foreground active:scale-95"
aria-label="Mese precedente"
>
<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-[13px] font-bold text-muted-foreground">
{giorniIT.map((g, i) => (
<span key={i}>{g}</span>
))}
</div>
{/*
Il mese si cambia anche con lo swipe: il punto d'arrivo si
decide proiettando la velocità di rilascio (come la
decelerazione dello scroll iOS), non dalla posizione del dito.
`dragElastic` dà la resistenza progressiva al bordo invece di
uno stop netto.
`p-1` con `-mx-1` compensato: l'anello del giorno corrente
(`ring-2 ring-offset-1`) sporge 3 px fuori dalla cella, e senza
questo margine interno `overflow-hidden` lo taglia sulla prima
riga e sulle colonne di bordo. Il padding sta dentro il riquadro
di ritaglio, i margini negativi rimettono la griglia dov'era.
*/}
<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;
// Attenzione: si conta per **tipo**, non per numero di
// eventi. Due partite nello stesso giorno restano una cella
// rossa piena; si divide solo se i tipi sono diversi.
const tipiGiorno = Array.from(new Set(eventiGiorno.map((e) => e.tipo)));
// Più tipi: la cella si divide in bande a taglio netto (gli
// stop sono duplicati apposta, non è una sfumatura), una per
// tipo, in diagonale.
const sfondo =
tipiGiorno.length > 1
? `linear-gradient(135deg, ${tipiGiorno
.map((t, idx) => {
const da = (idx / tipiGiorno.length) * 100;
const a = ((idx + 1) / tipiGiorno.length) * 100;
return `${coloreTipo[t]} ${da}%, ${coloreTipo[t]} ${a}%`;
})
.join(", ")})`
: undefined;
const tipo = tipiGiorno.length === 1 ? tipiGiorno[0] : undefined;
const isOggi =
!!oggi && oggi.anno === anno && oggi.mese === mese && oggi.giorno === giorno;
const Cella = haEventi ? "button" : "div";
return (
<Cella
key={giorno}
type={haEventi ? "button" : undefined}
onClick={haEventi ? () => apriGiorno(giorno) : undefined}
style={sfondo ? { backgroundImage: sfondo } : undefined}
className={cn(
"relative grid aspect-square place-items-center rounded-xl text-[15px] font-semibold",
tipo === "partita" && "bg-accent text-accent-foreground",
tipo === "allenamento" && "bg-training text-training-foreground",
tipo === "evento" && "bg-warning text-warning-foreground",
tipo === "compleanno" && "bg-success text-success-foreground",
!tipo && !haEventi && "text-muted-foreground",
!tipo && haEventi && "text-foreground",
haEventi && "cursor-pointer transition-transform active:scale-90",
isOggi && "ring-2 ring-foreground ring-offset-1 ring-offset-card",
)}
aria-label={
haEventi
? `${giorno} ${mesiIT[mese]}: ${eventiGiorno.length} ${eventiGiorno.length === 1 ? "evento" : "eventi"}`
: undefined
}
aria-current={isOggi ? "date" : undefined}
>
{/*
Sulle celle divise il numero sta direttamente sulle
bande, staccato dal fondo da un alone bianco: è la
resa scelta, il colore deve restare pieno e visibile
fino al bordo.
*/}
<span className="relative drop-shadow-[0_1px_1px_rgba(255,255,255,0.5)]">
{giorno}
</span>
</Cella>
);
})}
</motion.div>
</AnimatePresence>
</div>
<p className="mt-2 text-[13px] text-muted-foreground">
Scorri a destra o sinistra per cambiare mese.
</p>
<div className="mt-3 flex flex-wrap gap-3 text-[13px] font-semibold text-muted-foreground">
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-accent" /> Partita
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-training" /> Allenamento
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-warning" /> Eventi
</span>
<span className="inline-flex items-center gap-1">
<i className="h-2.5 w-2.5 rounded-full bg-success" /> Compleanni
</span>
</div>
</Card>
</Section>
{puoGestire ? (
<div className="px-5 pt-4">
<Link
to="/eventi"
className="premi flex items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<CalendarPlus className="h-4 w-4" /> Gestisci eventi
</Link>
</div>
) : null}
<Section titolo="Prossimi eventi">
<div className="space-y-3">
{prossimiEventi.length > 0 ? (
prossimiEventi.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun evento in programma
</p>
)}
</div>
</Section>
<Section titolo="Prossimi compleanni">
<div className="space-y-3">
{prossimiCompleanni.length > 0 ? (
prossimiCompleanni.map((c) => <EventoCard key={c.id} evento={c} />)
) : (
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
{compleanniMeseCorrente.length > 0
? "Nessun altro compleanno questo mese"
: "Nessun compleanno questo mese"}
</p>
)}
</div>
</Section>
<Drawer open={drawerAperto} onOpenChange={setDrawerAperto}>
<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 ? `${giornoSelezionato} ${mesiIT[mese]}` : "Eventi"}
</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-[60vh] space-y-3 overflow-y-auto py-2">
{eventiGiornoSelezionato.length > 0 ? (
eventiGiornoSelezionato.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-center text-[15px] text-muted-foreground shadow-card">
Nessun evento in questa data
</p>
)}
</div>
</DrawerContent>
</Drawer>
</>
);
}