feat(calendar): recurrencia (RRULE), multi-agenda, vista lista y linea de ahora

Backend (server/main.py):
- EventIn.rrule + emision/parseo de RRULE en el VCALENDAR.
- calendar() expande las series recurrentes a sus ocurrencias dentro de [from,to]
  (compone expand_rrule del registry); helpers _expand_event_occurrences /
  _occurrence_clone preservan hora local, offset y duracion por ocurrencia.
- POST /api/calendars: crea una coleccion de calendario nueva (compone
  dav_make_calendar); invalida la cache de colecciones.

Frontend:
- EventModal: controles de repeticion (frecuencia, intervalo, BYDAY para semanal,
  fin por N veces / hasta fecha); parseRrule/buildRrule; aviso 'afecta a la serie'.
- CalendarView: vista Lista/Agenda (eventos por dia, click para editar, nuevo
  evento), linea roja de hora actual (refresco cada 60s, solo columna de hoy),
  boton Nuevo calendario (modal nombre/color), indicador de recurrencia (IconRepeat).
- api.ts/calendar.ts: rrule/recurring/occurrence en los tipos, createCalendar,
  helpers nowLinePct/slugifyCalendar.

Verificado: tsc -b + vite build limpios; smoke backend (FREQ=WEEKLY;COUNT=3 -> 3
ocurrencias con hora/offset/duracion correctas); render en navegador (vista Lista,
Nuevo calendario, Nuevo evento, selectores presentes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:30:14 +02:00
parent 4a487b3d33
commit 5d5ce65e88
5 changed files with 866 additions and 41 deletions
+27
View File
@@ -132,6 +132,12 @@ export interface CalendarEvent {
location: string | null;
description: string | null;
color?: string | null;
// Regla RRULE cruda del evento maestro (sin prefijo "RRULE:"), p.ej.
// "FREQ=WEEKLY;INTERVAL=1;COUNT=10". `recurring` true si el evento repite;
// `occurrence` true si esta entrada es una ocurrencia expandida de una serie.
rrule?: string | null;
recurring?: boolean;
occurrence?: boolean;
href?: string;
etag?: string;
}
@@ -157,6 +163,21 @@ export interface CalendarsPayload {
error?: string;
}
// Cuerpo de POST /calendars para crear una colección de calendario nueva.
export interface CalendarInput {
slug: string;
name: string;
color?: string | null;
description?: string | null;
}
export interface CalendarWriteResult {
status: string;
href?: string;
existed?: boolean;
error?: string;
}
// Cuerpo de POST/PUT de un evento del calendario (VEVENT).
export interface EventInput {
cal?: string | null;
@@ -168,6 +189,9 @@ export interface EventInput {
location?: string | null;
description?: string | null;
color?: string | null;
// Cuerpo RRULE SIN el prefijo "RRULE:" (o null si no repite). Editar un evento
// recurrente reescribe toda la serie.
rrule?: string | null;
}
export interface EventWriteResult {
@@ -258,6 +282,9 @@ export const deleteContact = (slug: string) =>
export const fetchCalendars = () => getJSON<CalendarsPayload>("/calendars");
export const createCalendar = (data: CalendarInput) =>
sendJSON<CalendarWriteResult>("/calendars", "POST", data);
export const fetchCalendar = (cal = "", from = "", to = "") => {
const qs = new URLSearchParams();
if (cal) qs.set("cal", cal);
+123
View File
@@ -139,6 +139,129 @@ export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] {
export const HOURS = Array.from({ length: 24 }, (_, h) => h);
export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
// --- Recurrencia (RRULE) ---------------------------------------------------
export type RruleFreq = "none" | "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
export type RruleEndMode = "never" | "count" | "until";
// Días de la semana en orden L-M-X-J-V-S-D con su código BYDAY de iCalendar.
export const BYDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
export const BYDAY_LABELS = ["L", "M", "X", "J", "V", "S", "D"];
export interface RruleParts {
freq: RruleFreq;
interval: number;
endMode: RruleEndMode;
count: number;
until: string; // input date "YYYY-MM-DD" (vacío si no aplica)
byday: string[]; // códigos BYDAY ("MO", "TU", ...)
}
/**
* Construye el cuerpo RRULE (sin prefijo "RRULE:") a partir de los controles
* del modal. Devuelve "" si freq==="none". El `until` ("YYYY-MM-DD") se serializa
* como "UNTIL=YYYYMMDD" (sin guiones). Si endMode no es count/until, no añade
* cláusula de fin (recurrencia infinita).
*/
export function buildRrule(
freq: RruleFreq,
interval: number,
endMode: RruleEndMode,
count: number,
until: string,
byday?: string[],
): string {
if (freq === "none") return "";
const parts: string[] = [`FREQ=${freq}`];
const iv = Math.max(1, Math.floor(interval || 1));
parts.push(`INTERVAL=${iv}`);
if (freq === "WEEKLY" && byday && byday.length > 0) {
parts.push(`BYDAY=${byday.join(",")}`);
}
if (endMode === "count") {
parts.push(`COUNT=${Math.max(1, Math.floor(count || 1))}`);
} else if (endMode === "until" && until) {
parts.push(`UNTIL=${until.replace(/-/g, "")}`);
}
return parts.join(";");
}
/**
* Parsea un cuerpo RRULE crudo a los controles del modal. Tolerante: cadena
* vacía/indefinida → freq "none". Acepta un prefijo "RRULE:" residual por si el
* backend lo deja. UNTIL admite "YYYYMMDD" o "YYYYMMDDTHHMMSSZ".
*/
export function parseRrule(rrule: string | null | undefined): RruleParts {
const base: RruleParts = {
freq: "none",
interval: 1,
endMode: "never",
count: 10,
until: "",
byday: [],
};
if (!rrule) return base;
const body = rrule.replace(/^RRULE:/i, "").trim();
if (!body) return base;
const map = new Map<string, string>();
for (const seg of body.split(";")) {
const [k, v] = seg.split("=");
if (k && v !== undefined) map.set(k.toUpperCase(), v);
}
const freq = (map.get("FREQ") || "").toUpperCase();
if (
freq === "DAILY" ||
freq === "WEEKLY" ||
freq === "MONTHLY" ||
freq === "YEARLY"
) {
base.freq = freq as RruleFreq;
}
const iv = parseInt(map.get("INTERVAL") || "1", 10);
if (!Number.isNaN(iv) && iv > 0) base.interval = iv;
if (map.has("COUNT")) {
const c = parseInt(map.get("COUNT") || "", 10);
if (!Number.isNaN(c) && c > 0) {
base.endMode = "count";
base.count = c;
}
} else if (map.has("UNTIL")) {
const raw = map.get("UNTIL") || "";
const ymd = raw.slice(0, 8);
if (/^\d{8}$/.test(ymd)) {
base.endMode = "until";
base.until = `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
}
}
if (map.has("BYDAY")) {
base.byday = (map.get("BYDAY") || "")
.split(",")
.map((d) => d.trim().toUpperCase())
.filter((d) => BYDAY_CODES.includes(d));
}
return base;
}
/** Posición vertical (0..100) de la línea "ahora" dentro de la rejilla 024h. */
export function nowLinePct(now: dayjs.Dayjs): number {
return ((now.hour() * 60 + now.minute()) / (24 * 60)) * 100;
}
/**
* Deriva un slug de calendario de un nombre: minúsculas, espacios y caracteres
* fuera de [a-z0-9_-] → "-", colapsa guiones y recorta los de los extremos.
*/
export function slugifyCalendar(name: string): string {
return name
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // quita acentos (combining marks)
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/** Color efectivo de un evento: el propio del VEVENT o el del calendario. */
export function eventColor(
ev: CalendarEvent,
+390 -39
View File
@@ -6,16 +6,19 @@ import {
Box,
Button,
Center,
ColorInput,
ColorSwatch,
Group,
Indicator,
Loader,
Modal,
Paper,
ScrollArea,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
@@ -25,8 +28,10 @@ import {
IconChevronLeft,
IconChevronRight,
IconPlus,
IconRepeat,
} from "@tabler/icons-react";
import {
createCalendar,
deleteEvent,
fetchCalendar,
fetchCalendars,
@@ -36,10 +41,13 @@ import {
import {
dayjs,
eventColor,
eventEnd,
eventStart,
HOURS,
monthMatrix,
nowLinePct,
positionEventsForDay,
slugifyCalendar,
TIMEZONES,
WEEKDAY_LABELS,
weekDays,
@@ -47,7 +55,7 @@ import {
} from "../calendar";
import { EventModal, type EventDraft } from "./EventModal";
type ViewMode = "mes" | "semana" | "dia";
type ViewMode = "mes" | "semana" | "dia" | "lista";
// 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
@@ -66,34 +74,90 @@ export function CalendarView() {
const [cursor, setCursor] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [modal, setModal] = useState<EventDraft | null>(null);
const [reloadKey, setReloadKey] = useState(0);
// Instante "ahora" en la TZ del visor, refrescado cada minuto para la línea roja.
const [now, setNow] = useState(() => dayjs().tz(tz));
// Modal "nuevo calendario".
const [newCalOpen, setNewCalOpen] = useState(false);
const [newCalName, setNewCalName] = useState("");
const [newCalColor, setNewCalColor] = useState("");
const [newCalErr, setNewCalErr] = useState<string | null>(null);
const [newCalSaving, setNewCalSaving] = useState(false);
// Carga de calendarios (selector) una vez.
// Rango visible [from, to] (YYYY-MM-DD) según vista+cursor. El backend expande
// las series recurrentes dentro de este rango (una entrada por ocurrencia).
const range = useMemo(() => {
const c = dayjs(cursor);
if (view === "mes") {
const weeks = monthMatrix(c);
return {
from: weeks[0][0].format("YYYY-MM-DD"),
to: weeks[weeks.length - 1][6].add(1, "day").format("YYYY-MM-DD"),
};
}
if (view === "semana") {
const wd = weekDays(c);
return {
from: wd[0].format("YYYY-MM-DD"),
to: wd[6].add(1, "day").format("YYYY-MM-DD"),
};
}
if (view === "dia") {
return {
from: c.format("YYYY-MM-DD"),
to: c.add(1, "day").format("YYYY-MM-DD"),
};
}
// lista: ventana amplia desde hoy (~90 días) para una agenda útil.
return {
from: dayjs().format("YYYY-MM-DD"),
to: dayjs().add(90, "day").format("YYYY-MM-DD"),
};
}, [cursor, view]);
// Refresca el instante "ahora" cada minuto (línea roja en semana/día).
useEffect(() => {
let alive = true;
fetchCalendars()
setNow(dayjs().tz(tz));
const id = window.setInterval(() => setNow(dayjs().tz(tz)), 60000);
return () => window.clearInterval(id);
}, [tz]);
// Carga de calendarios (selector). `selectHref` permite seleccionar uno recién
// creado por su href cuando la lista se refresca tras crearlo.
const loadCalendars = useCallback((selectHref?: string) => {
return 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);
if (selectHref) {
setActiveCal(selectHref);
} else {
setActiveCal((cur) => {
if (cur) return cur;
if (d.default) return d.default;
if (d.calendars && d.calendars[0]) return d.calendars[0].href;
return cur;
});
}
}
return d;
})
.catch(() => {
/* el selector degrada a "calendario" implícito; no es fatal */
return null;
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Carga de eventos de la colección activa.
useEffect(() => {
loadCalendars();
}, [loadCalendars]);
// Carga de eventos de la colección activa, dentro del rango visible. Pasar
// from/to hace que el backend expanda las series recurrentes por ocurrencia.
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
fetchCalendar(activeCal)
fetchCalendar(activeCal, range.from, range.to)
.then((d) => {
if (!alive) return;
if (d.status !== "ok") {
@@ -107,7 +171,7 @@ export function CalendarView() {
return () => {
alive = false;
};
}, [activeCal, reloadKey]);
}, [activeCal, reloadKey, range.from, range.to]);
const calColor = useMemo(() => {
const c = calendars.find((c) => c.href === activeCal);
@@ -180,6 +244,7 @@ export function CalendarView() {
description: "",
color: "",
cal: activeCal,
rrule: "",
});
},
[tz, activeCal],
@@ -206,11 +271,53 @@ export function CalendarView() {
description: ev.description ?? "",
color: ev.color ?? "",
cal: activeCal,
rrule: ev.rrule ?? "",
});
},
[tz, activeCal],
);
// Crea un calendario nuevo derivando el slug del nombre, refresca la lista y
// selecciona el nuevo por su href.
const createNewCalendar = useCallback(async () => {
const name = newCalName.trim();
if (!name) {
setNewCalErr("El nombre es obligatorio.");
return;
}
const slug = slugifyCalendar(name);
if (!slug) {
setNewCalErr("El nombre no produce un identificador válido.");
return;
}
setNewCalSaving(true);
setNewCalErr(null);
try {
const res = await createCalendar({
slug,
name,
color: newCalColor || null,
});
if (res.status !== "ok") {
setNewCalErr(res.error || "No se pudo crear el calendario.");
return;
}
await loadCalendars(res.href);
notifications.show({
color: "teal",
title: "Calendario creado",
message: name,
});
setNewCalOpen(false);
setNewCalName("");
setNewCalColor("");
} catch (e) {
setNewCalErr(String(e));
} finally {
setNewCalSaving(false);
}
}, [newCalName, newCalColor, loadCalendars]);
function navigate(dir: -1 | 1) {
const unit = view === "mes" ? "month" : view === "semana" ? "week" : "day";
setCursor(dayjs(cursor).add(dir, unit).format("YYYY-MM-DD"));
@@ -233,12 +340,14 @@ export function CalendarView() {
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");
: view === "lista"
? "Agenda"
: 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">
@@ -279,20 +388,38 @@ export function CalendarView() {
}}
/>
<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 }}
/>
<Group align="flex-end" gap="xs" wrap="nowrap">
<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 }}
style={{ flex: 1, minWidth: 0 }}
/>
<Tooltip label="Nuevo calendario">
<ActionIcon
variant="default"
size="lg"
aria-label="Nuevo calendario"
onClick={() => {
setNewCalErr(null);
setNewCalName("");
setNewCalColor("");
setNewCalOpen(true);
}}
>
<IconPlus size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Select
label="Zona horaria"
@@ -341,6 +468,7 @@ export function CalendarView() {
{ value: "mes", label: "Mes" },
{ value: "semana", label: "Semana" },
{ value: "dia", label: "Día" },
{ value: "lista", label: "Lista" },
]}
/>
</Group>
@@ -362,12 +490,21 @@ export function CalendarView() {
}}
onEvent={openEdit}
/>
) : view === "lista" ? (
<AgendaView
events={events}
tz={tz}
calColor={calColor}
onNew={() => openNew(dayjs().format("YYYY-MM-DD"))}
onEvent={openEdit}
/>
) : view === "semana" ? (
<TimeGrid
days={weekDays(dayjs(cursor))}
events={events}
tz={tz}
calColor={calColor}
now={now}
onSlot={openNew}
onEvent={openEdit}
/>
@@ -377,6 +514,7 @@ export function CalendarView() {
events={events}
tz={tz}
calColor={calColor}
now={now}
onSlot={openNew}
onEvent={openEdit}
/>
@@ -391,6 +529,55 @@ export function CalendarView() {
onSaved={onSaved}
onDelete={onDelete}
/>
<Modal
opened={newCalOpen}
onClose={() => setNewCalOpen(false)}
title="Nuevo calendario"
size="sm"
centered
>
<Stack gap="sm">
<TextInput
label="Nombre"
placeholder="Trabajo, Personal, OSINT…"
value={newCalName}
onChange={(e) => setNewCalName(e.currentTarget.value)}
data-autofocus
required
/>
<ColorInput
label="Color"
placeholder="Color del calendario (opcional)"
value={newCalColor}
onChange={setNewCalColor}
format="hex"
swatches={[
"#23bdfe",
"#16a34a",
"#dc2626",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#0891b2",
"#64748b",
]}
/>
{newCalErr && (
<Text c="red" size="sm">
{newCalErr}
</Text>
)}
<Group justify="flex-end" gap="xs" mt="xs">
<Button variant="default" onClick={() => setNewCalOpen(false)}>
Cancelar
</Button>
<Button onClick={createNewCalendar} loading={newCalSaving}>
Crear
</Button>
</Group>
</Stack>
</Modal>
</Group>
);
}
@@ -402,6 +589,7 @@ function TimeGrid({
events,
tz,
calColor,
now,
onSlot,
onEvent,
}: {
@@ -409,11 +597,14 @@ function TimeGrid({
events: CalendarEvent[];
tz: string;
calColor: string | null;
now: dayjs.Dayjs;
onSlot: (day: string, hour: number) => void;
onEvent: (ev: CalendarEvent) => void;
}) {
const HOUR_PX = 44;
const today = dayjs().format("YYYY-MM-DD");
// "Hoy" según la TZ del visor (no la del navegador) para alinear con la línea.
const today = now.format("YYYY-MM-DD");
const nowPct = nowLinePct(now);
return (
<ScrollArea h="100%" type="auto">
<Box style={{ display: "flex", minWidth: days.length > 1 ? 640 : 0 }}>
@@ -523,12 +714,45 @@ function TimeGrid({
cursor: "pointer",
}}
>
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
<Group gap={3} wrap="nowrap" align="center">
{p.ev.recurring && (
<IconRepeat size={11} style={{ flexShrink: 0 }} />
)}
<Text size="xs" fw={600} truncate c="#fff">
{eventStart(p.ev, tz)?.format("HH:mm")} ·{" "}
{p.ev.summary || "(sin título)"}
</Text>
</Group>
</Box>
))}
{/* Línea roja "ahora" — solo en la columna del día de hoy */}
{isToday && (
<Box
style={{
position: "absolute",
top: `${nowPct}%`,
left: 0,
right: 0,
height: 0,
borderTop: "2px solid var(--mantine-color-red-6)",
zIndex: 5,
pointerEvents: "none",
}}
>
<Box
style={{
position: "absolute",
left: -3,
top: -4,
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--mantine-color-red-6)",
}}
/>
</Box>
)}
</Box>
</Box>
);
@@ -679,6 +903,9 @@ function EventChip({
variant="filled"
radius="sm"
fullWidth
leftSection={
ev.recurring ? <IconRepeat size={10} style={{ display: "block" }} /> : undefined
}
style={{
background: color,
cursor: "pointer",
@@ -692,3 +919,127 @@ function EventChip({
</Badge>
);
}
// --- Vista Lista / Agenda: eventos del rango, agrupados por día ------------
function AgendaView({
events,
tz,
calColor,
onNew,
onEvent,
}: {
events: CalendarEvent[];
tz: string;
calColor: string | null;
onNew: () => void;
onEvent: (ev: CalendarEvent) => void;
}) {
// Orden ascendente por instante de inicio + agrupado por día (clave YYYY-MM-DD).
const groups = useMemo(() => {
const sorted = [...events].sort((a, b) => {
const sa = eventStart(a, tz);
const sb = eventStart(b, tz);
const va = sa ? sa.valueOf() : Number.POSITIVE_INFINITY;
const vb = sb ? sb.valueOf() : Number.POSITIVE_INFINITY;
return va - vb;
});
const map = new Map<string, CalendarEvent[]>();
for (const ev of sorted) {
const s = eventStart(ev, tz);
if (!s) continue;
const k = s.format("YYYY-MM-DD");
const list = map.get(k) ?? [];
list.push(ev);
map.set(k, list);
}
return Array.from(map.entries());
}, [events, tz]);
const today = dayjs().tz(tz).format("YYYY-MM-DD");
return (
<ScrollArea h="100%" type="auto">
<Box p="md">
<Group justify="space-between" mb="sm" wrap="nowrap">
<Text fw={600}>Próximos eventos</Text>
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={onNew}
>
Nuevo evento
</Button>
</Group>
{groups.length === 0 ? (
<Text c="dimmed" size="sm">
No hay eventos en el rango.
</Text>
) : (
<Stack gap="lg">
{groups.map(([dayKey, list]) => {
const d = dayjs(dayKey);
const isToday = dayKey === today;
return (
<Box key={dayKey}>
<Text
size="sm"
fw={600}
c={isToday ? "brand" : undefined}
mb={6}
style={{ textTransform: "capitalize" }}
>
{d.format("dddd")} · {d.format("DD/MM/YYYY")}
</Text>
<Stack gap={4}>
{list.map((ev, i) => {
const s = eventStart(ev, tz);
const e = eventEnd(ev, tz);
const timeLabel = ev.all_day
? "Todo el día"
: `${s?.format("HH:mm") ?? "--:--"}${
e ? ` ${e.format("HH:mm")}` : ""
}`;
return (
<Group
key={(ev.uid ?? "") + i}
gap="sm"
wrap="nowrap"
onClick={() => onEvent(ev)}
style={{
cursor: "pointer",
borderRadius: 6,
padding: "6px 8px",
borderLeft: `3px solid ${eventColor(ev, calColor)}`,
background: "var(--mantine-color-default-hover)",
}}
>
<Text
size="xs"
c="dimmed"
style={{ width: 96, flexShrink: 0 }}
>
{timeLabel}
</Text>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{ev.recurring && (
<IconRepeat size={13} style={{ flexShrink: 0 }} />
)}
<Text size="sm" truncate>
{ev.summary || "(sin título)"}
</Text>
</Group>
</Group>
);
})}
</Stack>
</Box>
);
})}
</Stack>
)}
</Box>
</ScrollArea>
);
}
+136 -1
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react";
import {
Button,
Chip,
ColorInput,
Group,
Modal,
NumberInput,
Select,
Stack,
Switch,
@@ -18,7 +20,15 @@ import {
type CalendarCollection,
type EventInput,
} from "../api";
import { TIMEZONES } from "../calendar";
import {
TIMEZONES,
buildRrule,
parseRrule,
BYDAY_CODES,
BYDAY_LABELS,
type RruleFreq,
type RruleEndMode,
} from "../calendar";
// Borrador de evento que el CalendarView pasa al modal. `mode` decide si el
// guardado hace POST (new) o PUT (edit). Las fechas van en ISO local
@@ -35,8 +45,33 @@ export interface EventDraft {
description: string;
color: string;
cal: string;
// Cuerpo RRULE crudo del maestro (sin prefijo "RRULE:"). "" = no se repite.
rrule: string;
}
// Opciones del Select de frecuencia y sufijo textual del intervalo por frecuencia.
const FREQ_OPTIONS: { value: RruleFreq; label: string }[] = [
{ value: "none", label: "No se repite" },
{ value: "DAILY", label: "Cada día" },
{ value: "WEEKLY", label: "Cada semana" },
{ value: "MONTHLY", label: "Cada mes" },
{ value: "YEARLY", label: "Cada año" },
];
const INTERVAL_SUFFIX: Record<RruleFreq, string> = {
none: "",
DAILY: "días",
WEEKLY: "semanas",
MONTHLY: "meses",
YEARLY: "años",
};
const END_OPTIONS: { value: RruleEndMode; label: string }[] = [
{ value: "never", label: "Nunca" },
{ value: "count", label: "Tras N veces" },
{ value: "until", label: "En fecha" },
];
export function EventModal({
draft,
calendars,
@@ -54,13 +89,36 @@ export function EventModal({
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Controles de recurrencia, derivados del rrule crudo al abrir el modal.
const [freq, setFreq] = useState<RruleFreq>("none");
const [interval, setInterval] = useState<number>(1);
const [endMode, setEndMode] = useState<RruleEndMode>("never");
const [count, setCount] = useState<number>(10);
const [until, setUntil] = useState<string>("");
const [byday, setByday] = useState<string[]>([]);
useEffect(() => {
setForm(draft);
setErr(null);
const p = parseRrule(draft?.rrule ?? "");
setFreq(p.freq);
setInterval(p.interval);
setEndMode(p.endMode);
setCount(p.count);
setUntil(p.until);
setByday(p.byday);
}, [draft]);
if (!form) return null;
const recurs = freq !== "none";
function toggleByday(code: string) {
setByday((days) =>
days.includes(code) ? days.filter((d) => d !== code) : [...days, code],
);
}
function set<K extends keyof EventDraft>(key: K, value: EventDraft[K]) {
setForm((f) => (f ? { ...f, [key]: value } : f));
}
@@ -108,6 +166,7 @@ export function EventModal({
location: form.location || null,
description: form.description || null,
color: form.color || null,
rrule: buildRrule(freq, interval, endMode, count, until, byday) || null,
};
try {
if (form.mode === "edit" && form.uid) {
@@ -179,6 +238,82 @@ export function EventModal({
/>
)}
{/* Repetición (RRULE) */}
<Select
label="Repetición"
data={FREQ_OPTIONS}
value={freq}
onChange={(v) => v && setFreq(v as RruleFreq)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
{recurs && (
<>
<Group align="flex-end" gap="xs" wrap="nowrap">
<NumberInput
label="Cada"
min={1}
value={interval}
onChange={(v) => setInterval(typeof v === "number" ? v : 1)}
style={{ width: 110 }}
/>
<Text size="sm" mb={8} c="dimmed">
{INTERVAL_SUFFIX[freq]}
</Text>
</Group>
{freq === "WEEKLY" && (
<Chip.Group multiple value={byday}>
<Group gap={4} wrap="wrap">
{BYDAY_CODES.map((code, i) => (
<Chip
key={code}
value={code}
size="xs"
checked={byday.includes(code)}
onClick={() => toggleByday(code)}
>
{BYDAY_LABELS[i]}
</Chip>
))}
</Group>
</Chip.Group>
)}
<Select
label="Termina"
data={END_OPTIONS}
value={endMode}
onChange={(v) => v && setEndMode(v as RruleEndMode)}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
{endMode === "count" && (
<NumberInput
label="Número de repeticiones"
min={1}
value={count}
onChange={(v) => setCount(typeof v === "number" ? v : 1)}
/>
)}
{endMode === "until" && (
<TextInput
label="Fecha de fin"
type="date"
value={until}
onChange={(e) => setUntil(e.currentTarget.value)}
/>
)}
<Text size="xs" c="dimmed">
Los cambios se aplican a toda la serie.
</Text>
</>
)}
<Select
label="Calendario"
data={calendars.map((c) => ({ value: c.href, label: c.name }))}