feat(calendar): vista mes/semana/día, TZ, selector de calendario, colores y CRUD de eventos

Backend (server/main.py):
- GET /api/calendars: lista las colecciones de calendario bajo el calendar-home
  con nombre y color (compone dav_list_calendars del registry).
- GET /api/calendar?cal=&from=&to=: eventos de una colección concreta (caché por
  colección validada por ctag). dtstart/dtend ahora en ISO con offset + tz
  original + all_day; parseo robusto de TZID/UTC/todo-el-día con zoneinfo.
- POST/PUT/DELETE /api/event[/<uid>]: CRUD de VEVENT contra Xandikos (fuente de
  verdad). Construye el VCALENDAR (con VTIMEZONE para zonas con DST), reutiliza el
  UID al editar (idempotente), trata 404 del DELETE como idempotente, invalida la
  caché de la colección tras escribir.

Frontend:
- CalendarView reescrita: conmutador Mes/Semana/Día con rejilla horaria propia
  (Mantine + dayjs, sin react-big-calendar para evitar fricción con React 19),
  mini-calendario de navegación, selector de calendario (con color), selector de
  zona horaria que recoloca los eventos, colores por evento (del VEVENT o del
  calendario).
- EventModal: alta/edición/borrado con summary, inicio/fin, todo-el-día, TZ,
  calendario, color, ubicación y descripción. Fechas en formato local 24h.
- calendar.ts: helpers de TZ (dayjs utc+timezone), posicionado por hora, semana
  empezando en lunes, locale es. api.ts: tipos y funciones de eventos/calendarios.

Verificado: ciclo real crear→editar→borrar contra Xandikos (cero residuo),
render del calendario en navegador (React 19 + Mantine v9 montan), pnpm build
verde, 40 tests verdes (+ smoke gateado). MKCALENDAR queda fuera (documentado).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-12 00:40:59 +02:00
parent 43889bfc07
commit e792bc6e17
8 changed files with 2000 additions and 179 deletions
+600 -126
View File
@@ -1,45 +1,99 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
ColorSwatch,
Group,
Indicator,
Loader,
Paper,
ScrollArea,
SegmentedControl,
Select,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { Calendar } from "@mantine/dates";
import dayjs from "dayjs";
import { IconClock, IconMapPin } from "@tabler/icons-react";
import { fetchCalendar, type CalendarEvent } from "../api";
import { notifications } from "@mantine/notifications";
import {
dayLabel,
formatICalTime,
icalDayKey,
} from "../format";
IconChevronLeft,
IconChevronRight,
IconPlus,
} from "@tabler/icons-react";
import {
deleteEvent,
fetchCalendar,
fetchCalendars,
type CalendarCollection,
type CalendarEvent,
} from "../api";
import {
dayjs,
eventColor,
eventStart,
HOURS,
monthMatrix,
positionEventsForDay,
TIMEZONES,
WEEKDAY_LABELS,
weekDays,
browserTz,
} from "../calendar";
import { EventModal, type EventDraft } from "./EventModal";
// Calendario: mini-calendario de @mantine/dates a la izquierda (con punto en
// los días que tienen eventos) y la lista de eventos a la derecha. Por defecto
// muestra el mes actual agrupado por día; al elegir un día se filtra a ese día.
type ViewMode = "mes" | "semana" | "dia";
// Calendario: vista Mes/Semana/Día con eventos posicionados por hora, selector
// de calendario (con color), selector de zona horaria, colores por evento y CRUD
// completo (crear en un hueco, editar/borrar un evento). El mini-calendario de la
// izquierda navega; el cuerpo muestra la vista activa en la TZ seleccionada.
export function CalendarView() {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [activeCal, setActiveCal] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Mantine v9 Calendar usa fechas como string "YYYY-MM-DD" (DateStringValue),
// no Date. `month` controla el mes mostrado; `selectedDay` filtra a un día.
const [month, setMonth] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const [view, setView] = useState<ViewMode>("semana");
const [tz, setTz] = useState<string>(browserTz());
// `cursor` es el día de referencia (la vista lo expande a su mes/semana/día).
const [cursor, setCursor] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [modal, setModal] = useState<EventDraft | null>(null);
const [reloadKey, setReloadKey] = useState(0);
// Carga de calendarios (selector) una vez.
useEffect(() => {
let alive = true;
fetchCalendars()
.then((d) => {
if (!alive) return;
if (d.status === "ok" && d.calendars) {
setCalendars(d.calendars);
if (!activeCal && d.default) setActiveCal(d.default);
else if (!activeCal && d.calendars[0]) setActiveCal(d.calendars[0].href);
}
})
.catch(() => {
/* el selector degrada a "calendario" implícito; no es fatal */
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Carga de eventos de la colección activa.
useEffect(() => {
let alive = true;
setLoading(true);
fetchCalendar()
setError(null);
fetchCalendar(activeCal)
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
@@ -53,33 +107,114 @@ export function CalendarView() {
return () => {
alive = false;
};
}, []);
}, [activeCal, reloadKey]);
// Eventos indexados por día local "AAAA-MM-DD".
const byDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
const calColor = useMemo(() => {
const c = calendars.find((c) => c.href === activeCal);
return c?.color ?? null;
}, [calendars, activeCal]);
// Días con eventos (para el punto en el mini-calendario), en la TZ activa.
const daysWithEvents = useMemo(() => {
const set = new Set<string>();
for (const e of events) {
const key = icalDayKey(e.dtstart);
if (!key) continue;
const list = map.get(key) ?? [];
list.push(e);
map.set(key, list);
const s = eventStart(e, tz);
if (s) set.add(s.format("YYYY-MM-DD"));
}
return map;
}, [events]);
return set;
}, [events, tz]);
// Días visibles: si hay día seleccionado, solo ese; si no, todos los del mes
// mostrado, ordenados.
const visibleDays = useMemo(() => {
const monthPrefix = month.slice(0, 7); // "YYYY-MM"
let keys = [...byDay.keys()];
if (selectedDay) {
keys = keys.filter((k) => k === selectedDay);
} else {
keys = keys.filter((k) => k.startsWith(monthPrefix));
}
return keys.sort();
}, [byDay, month, selectedDay]);
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
const onSaved = useCallback(
(msg: string) => {
notifications.show({ color: "teal", title: "Calendario", message: msg });
setModal(null);
reload();
},
[reload],
);
const onDelete = useCallback(
async (uid: string) => {
try {
await deleteEvent(uid, activeCal);
notifications.show({
color: "teal",
title: "Evento borrado",
message: "",
});
setModal(null);
reload();
} catch (e) {
notifications.show({
color: "red",
title: "No se pudo borrar",
message: String(e),
});
}
},
[activeCal, reload],
);
// Abre el modal en modo "nuevo" en un hueco (día + hora opcional).
const openNew = useCallback(
(day: string, hour?: number) => {
const start =
hour === undefined
? `${day}T09:00`
: `${day}T${String(hour).padStart(2, "0")}:00`;
const endHour = hour === undefined ? 10 : Math.min(hour + 1, 23);
const end =
hour === undefined
? `${day}T10:00`
: `${day}T${String(endHour).padStart(2, "0")}:00`;
setModal({
mode: "new",
summary: "",
dtstart: start,
dtend: end,
tz,
all_day: false,
location: "",
description: "",
color: "",
cal: activeCal,
});
},
[tz, activeCal],
);
// Abre el modal en modo "editar" desde un evento existente.
const openEdit = useCallback(
(ev: CalendarEvent) => {
const s = eventStart(ev, tz);
const e = ev.dtend ? eventStart({ ...ev, dtstart: ev.dtend }, tz) : null;
setModal({
mode: "edit",
uid: ev.uid ?? undefined,
summary: ev.summary ?? "",
dtstart: ev.all_day
? (ev.dtstart ?? "").slice(0, 10)
: (s?.format("YYYY-MM-DDTHH:mm") ?? ""),
dtend: ev.all_day
? (ev.dtend ?? "").slice(0, 10)
: (e?.format("YYYY-MM-DDTHH:mm") ?? ""),
tz: ev.tz || tz,
all_day: !!ev.all_day,
location: ev.location ?? "",
description: ev.description ?? "",
color: ev.color ?? "",
cal: activeCal,
});
},
[tz, activeCal],
);
function navigate(dir: -1 | 1) {
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
}
if (error) {
return (
@@ -95,126 +230,465 @@ export function CalendarView() {
);
}
if (loading) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
const headerTitle =
view === "mes"
? dayjs(cursor).format("MMMM YYYY")
: view === "semana"
? (() => {
const wd = weekDays(dayjs(cursor));
return `${wd[0].format("D MMM")} ${wd[6].format("D MMM YYYY")}`;
})()
: dayjs(cursor).format("dddd D [de] MMMM YYYY");
return (
<Group h="100%" gap={0} wrap="nowrap" align="stretch">
{/* Panel izquierdo: navegación + selectores */}
<Paper
p="md"
radius={0}
withBorder
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0 }}
style={{ borderTop: 0, borderBottom: 0, borderLeft: 0, width: 280 }}
>
<Stack gap="md">
<Button
leftSection={<IconPlus size={16} />}
onClick={() => openNew(cursor)}
fullWidth
>
Nuevo evento
</Button>
<Calendar
date={month}
onDateChange={setMonth}
date={cursor}
onDateChange={(d) => setCursor(d)}
getDayProps={(date) => ({
selected: selectedDay === date,
onClick: () =>
setSelectedDay((prev) => (prev === date ? null : date)),
selected: cursor === date,
onClick: () => {
setCursor(date);
if (view === "mes") setView("dia");
},
})}
renderDay={(date) => {
const has = byDay.has(date);
const has = daysWithEvents.has(date);
const day = Number(date.slice(8, 10));
return (
<Indicator
size={6}
color="brand"
offset={-2}
disabled={!has}
>
<Indicator size={6} color="brand" offset={-2} disabled={!has}>
<div>{day}</div>
</Indicator>
);
}}
/>
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}
value={activeCal || null}
onChange={(v) => v && setActiveCal(v)}
leftSection={
<ColorSwatch
size={14}
color={eventColor({} as CalendarEvent, calColor)}
/>
}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
<Select
label="Zona horaria"
data={TIMEZONES}
value={tz}
onChange={(v) => v && setTz(v)}
searchable
comboboxProps={{ withinPortal: true }}
/>
<Text size="xs" c="dimmed">
{events.length} eventos en total
{events.length} eventos · TZ {tz}
</Text>
{selectedDay && (
<Badge
variant="light"
style={{ cursor: "pointer" }}
onClick={() => setSelectedDay(null)}
>
Ver todo el mes
</Badge>
)}
</Stack>
</Paper>
<Box style={{ flex: 1, minWidth: 0 }}>
<ScrollArea h="100%">
<Stack p="xl" gap="lg" maw={760}>
<Title order={3}>
{selectedDay
? dayLabel(selectedDay)
: dayjs(month).format("MMMM YYYY")}
{/* Cuerpo: barra de vista + rejilla */}
<Box style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<Group justify="space-between" p="sm" wrap="nowrap" style={{ flexShrink: 0 }}>
<Group gap="xs" wrap="nowrap">
<Tooltip label="Anterior">
<ActionIcon variant="default" onClick={() => navigate(-1)}>
<IconChevronLeft size={16} />
</ActionIcon>
</Tooltip>
<Button
variant="default"
size="xs"
onClick={() => setCursor(dayjs().format("YYYY-MM-DD"))}
>
Hoy
</Button>
<Tooltip label="Siguiente">
<ActionIcon variant="default" onClick={() => navigate(1)}>
<IconChevronRight size={16} />
</ActionIcon>
</Tooltip>
<Title order={4} style={{ textTransform: "capitalize" }}>
{headerTitle}
</Title>
</Group>
<SegmentedControl
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{ value: "mes", label: "Mes" },
{ value: "semana", label: "Semana" },
{ value: "dia", label: "Día" },
]}
/>
</Group>
{visibleDays.length === 0 && (
<Text c="dimmed">Sin eventos en este periodo.</Text>
)}
{visibleDays.map((day) => (
<Stack key={day} gap="xs">
{!selectedDay && (
<Text fw={600} size="sm" c="brand">
{dayLabel(day)}
</Text>
)}
{(byDay.get(day) ?? [])
.sort((a, b) =>
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""),
)
.map((ev, i) => (
<EventRow key={(ev.uid ?? "") + i} ev={ev} />
))}
</Stack>
))}
</Stack>
</ScrollArea>
<Box style={{ flex: 1, minHeight: 0 }}>
{loading ? (
<Center h="100%">
<Loader />
</Center>
) : view === "mes" ? (
<MonthView
cursor={cursor}
events={events}
tz={tz}
calColor={calColor}
onDay={(d) => {
setCursor(d);
setView("dia");
}}
onEvent={openEdit}
/>
) : view === "semana" ? (
<TimeGrid
days={weekDays(dayjs(cursor))}
events={events}
tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
) : (
<TimeGrid
days={[dayjs(cursor)]}
events={events}
tz={tz}
calColor={calColor}
onSlot={openNew}
onEvent={openEdit}
/>
)}
</Box>
</Box>
<EventModal
draft={modal}
calendars={calendars}
onClose={() => setModal(null)}
onSaved={onSaved}
onDelete={onDelete}
/>
</Group>
);
}
function EventRow({ ev }: { ev: CalendarEvent }) {
// --- Vista de semana / día: rejilla horas × días ---------------------------
function TimeGrid({
days,
events,
tz,
calColor,
onSlot,
onEvent,
}: {
days: dayjs.Dayjs[];
events: CalendarEvent[];
tz: string;
calColor: string | null;
onSlot: (day: string, hour: number) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const HOUR_PX = 44;
const today = dayjs().format("YYYY-MM-DD");
return (
<Paper withBorder p="sm" radius="md">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Box style={{ minWidth: 0 }}>
<Text fw={600} size="sm">
{ev.summary || "(sin título)"}
</Text>
{ev.location && (
<Group gap={4} mt={2}>
<IconMapPin size={13} />
<Text size="xs" c="dimmed">
{ev.location}
</Text>
</Group>
)}
{ev.description && (
<Text size="xs" c="dimmed" mt={4} lineClamp={2}>
{ev.description}
</Text>
)}
<ScrollArea h="100%" type="auto">
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
{/* Columna de horas */}
<Box style={{ width: 52, flexShrink: 0 }}>
<Box style={{ height: 28 }} />
{HOURS.map((h) => (
<Box
key={h}
style={{
height: HOUR_PX,
textAlign: "right",
paddingRight: 6,
fontSize: 11,
color: "var(--mantine-color-dimmed)",
transform: "translateY(-7px)",
}}
>
{String(h).padStart(2, "0")}:00
</Box>
))}
</Box>
<Group gap={4} wrap="nowrap">
<IconClock size={13} />
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{formatICalTime(ev.dtstart)}
</Text>
</Group>
</Group>
</Paper>
{/* Una columna por día */}
{days.map((day) => {
const dayKey = day.format("YYYY-MM-DD");
const positioned = positionEventsForDay(events, dayKey, tz);
const allDay = positioned.filter((p) => p.allDay);
const timed = positioned.filter((p) => !p.allDay);
const isToday = dayKey === today;
return (
<Box
key={dayKey}
style={{
flex: 1,
minWidth: 0,
borderLeft: "1px solid var(--mantine-color-default-border)",
}}
>
{/* Cabecera del día */}
<Box
style={{
height: 28,
textAlign: "center",
fontSize: 12,
fontWeight: 600,
color: isToday
? "var(--mantine-color-brand-6)"
: "var(--mantine-color-text)",
}}
>
{WEEKDAY_LABELS[(day.day() + 6) % 7]} {day.format("D")}
</Box>
{/* Franja de "todo el día" */}
{allDay.length > 0 && (
<Box style={{ padding: "2px 3px" }}>
{allDay.map((p, i) => (
<EventChip
key={(p.ev.uid ?? "") + i}
ev={p.ev}
calColor={calColor}
onClick={() => onEvent(p.ev)}
compact
/>
))}
</Box>
)}
{/* Rejilla horaria con posicionado absoluto */}
<Box style={{ position: "relative", height: HOUR_PX * 24 }}>
{HOURS.map((h) => (
<Box
key={h}
onClick={() => onSlot(dayKey, h)}
style={{
position: "absolute",
top: h * HOUR_PX,
left: 0,
right: 0,
height: HOUR_PX,
borderTop: "1px solid var(--mantine-color-default-border)",
cursor: "pointer",
}}
/>
))}
{timed.map((p, i) => (
<Box
key={(p.ev.uid ?? "") + i}
onClick={(e) => {
e.stopPropagation();
onEvent(p.ev);
}}
style={{
position: "absolute",
top: `${p.topPct}%`,
height: `${p.heightPct}%`,
left: 2,
right: 2,
overflow: "hidden",
borderRadius: 4,
padding: "1px 5px",
fontSize: 11,
lineHeight: 1.25,
color: "#fff",
background: eventColor(p.ev, calColor),
cursor: "pointer",
}}
>
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
</Box>
))}
</Box>
</Box>
);
})}
</Box>
</ScrollArea>
);
}
// --- Vista de mes: rejilla de semanas --------------------------------------
function MonthView({
cursor,
events,
tz,
calColor,
onDay,
onEvent,
}: {
cursor: string;
events: CalendarEvent[];
tz: string;
calColor: string | null;
onDay: (day: string) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const weeks = monthMatrix(dayjs(cursor));
const month = dayjs(cursor).month();
const today = dayjs().format("YYYY-MM-DD");
const byDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
const s = eventStart(e, tz);
if (!s) continue;
const k = s.format("YYYY-MM-DD");
const list = map.get(k) ?? [];
list.push(e);
map.set(k, list);
}
return map;
}, [events, tz]);
return (
<ScrollArea h="100%">
<Box style={{ display: "flex", padding: "0 8px" }}>
{WEEKDAY_LABELS.map((l) => (
<Box
key={l}
style={{
flex: 1,
textAlign: "center",
fontSize: 11,
fontWeight: 600,
color: "var(--mantine-color-dimmed)",
padding: "4px 0",
}}
>
{l}
</Box>
))}
</Box>
<Stack gap={0} px={8} pb={8}>
{weeks.map((week, wi) => (
<Group key={wi} gap={0} wrap="nowrap" align="stretch">
{week.map((day) => {
const k = day.format("YYYY-MM-DD");
const list = (byDay.get(k) ?? []).sort((a, b) =>
(a.dtstart ?? "").localeCompare(b.dtstart ?? ""),
);
const dim = day.month() !== month;
const isToday = k === today;
return (
<Box
key={k}
onClick={() => onDay(k)}
style={{
flex: 1,
minWidth: 0,
minHeight: 92,
border: "1px solid var(--mantine-color-default-border)",
padding: 3,
cursor: "pointer",
background: dim
? "var(--mantine-color-default-hover)"
: undefined,
}}
>
<Text
size="xs"
ta="right"
fw={isToday ? 700 : 400}
c={
isToday
? "brand"
: dim
? "dimmed"
: undefined
}
>
{day.format("D")}
</Text>
<Stack gap={2} mt={2}>
{list.slice(0, 3).map((ev, i) => (
<EventChip
key={(ev.uid ?? "") + i}
ev={ev}
calColor={calColor}
onClick={() => onEvent(ev)}
compact
/>
))}
{list.length > 3 && (
<Text size="xs" c="dimmed">
+{list.length - 3} más
</Text>
)}
</Stack>
</Box>
);
})}
</Group>
))}
</Stack>
</ScrollArea>
);
}
// Chip compacto de un evento (mes / franja all-day).
function EventChip({
ev,
calColor,
onClick,
compact,
}: {
ev: CalendarEvent;
calColor: string | null;
onClick: () => void;
compact?: boolean;
}) {
const color = eventColor(ev, calColor);
return (
<Badge
onClick={(e) => {
e.stopPropagation();
onClick();
}}
variant="filled"
radius="sm"
fullWidth
style={{
background: color,
cursor: "pointer",
justifyContent: "flex-start",
textTransform: "none",
fontWeight: 500,
height: compact ? 16 : 20,
}}
>
{ev.summary || "(sin título)"}
</Badge>
);
}