merge: calendario con recurrencia, multi-agenda, vista lista y linea de ahora

This commit is contained in:
2026-06-12 23:30:14 +02:00
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 }))}
+190 -1
View File
@@ -165,6 +165,10 @@ dav_delete_resource = _load_infra_fn("dav_delete_resource", "dav_delete_resource
# las colecciones de calendario del usuario con su nombre y color.
caldav_put_event = _load_infra_fn("caldav_put_event", "caldav_put_event")
dav_list_calendars = _load_infra_fn("dav_list_calendars", "dav_list_calendars")
# Crear una colección de calendario nueva (MKCALENDAR + PROPPATCH nombre/color).
dav_make_calendar = _load_infra_fn("dav_make_calendar", "dav_make_calendar")
# Expandir una RRULE a las fechas de cada ocurrencia dentro de un rango (pura).
expand_rrule = _load_infra_fn("expand_rrule", "expand_rrule")
# ---------------------------------------------------------------------------
@@ -705,6 +709,42 @@ class VaultState:
self._maybe_clear_force_reload()
return calendars
def create_calendar(self, data: "CalendarIn") -> dict:
"""Crea una colección de calendario nueva bajo el calendar-home (MKCALENDAR).
Compone la función del registry ``dav_make_calendar`` (MKCALENDAR +
PROPPATCH de nombre/color). Invalida la caché de colecciones para que el
calendario nuevo aparezca en el selector al recargar.
Returns:
dict ``{status, href, existed?}`` de la función del registry.
Raises:
HTTPException(400): si el slug/nombre queda vacío tras sanear.
DavUnavailable: si Xandikos rechaza la creación.
"""
slug = (data.slug or data.name or "").strip()
if not slug:
raise HTTPException(status_code=400, detail="el nombre del calendario es obligatorio")
password = self.xandikos_password()
res = dav_make_calendar(
XANDIKOS_BASE_URL,
XANDIKOS_USERNAME,
password,
XANDIKOS_CALENDAR_HOME,
slug,
data.name or slug,
data.color or "",
data.description or "",
)
if res.get("status") != "ok":
raise DavUnavailable(
"Xandikos no pudo crear el calendario: %s" % res.get("error")
)
with self._dav_lock:
self._calendars_cache = None
return res
def calendar(self, cal: str = "", dt_from: str = "", dt_to: str = "") -> list:
"""Eventos de una colección de calendario Xandikos, cacheados y filtrados.
@@ -732,9 +772,18 @@ class VaultState:
self._maybe_clear_force_reload()
cached = events
all_events = list(cached)
# Sin rango: devolvemos los eventos maestros tal cual (no expandimos
# series infinitas). Con rango: cada serie recurrente se expande a sus
# ocurrencias dentro de [from, to]; los puntuales se filtran por fecha.
if not dt_from and not dt_to:
return all_events
return [e for e in all_events if _event_in_range(e, dt_from, dt_to)]
out: list = []
for ev in all_events:
if ev.get("rrule"):
out.extend(_expand_event_occurrences(ev, dt_from, dt_to))
elif _event_in_range(ev, dt_from, dt_to):
out.append(ev)
return out
# --- Escritura de eventos del calendario (CalDAV) -----------------------
@@ -1262,6 +1311,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
"location": None,
"description": None,
"color": None,
"rrule": None,
"recurring": False,
"occurrence": False,
}
for line in _unfold_lines(vevent_block):
parsed = _parse_property(line)
@@ -1295,6 +1347,9 @@ def _vevent_to_json(vevent_block: str) -> dict:
out["description"] = _unescape_ical(value)
elif name in ("COLOR", "X-APPLE-CALENDAR-COLOR"):
out["color"] = value
elif name == "RRULE":
out["rrule"] = value
out["recurring"] = True
return out
@@ -1327,6 +1382,98 @@ def _event_in_range(event: dict, dt_from: str, dt_to: str) -> bool:
return True
def _default_expand_start() -> str:
"""Límite inferior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) - timedelta(days=366)).strftime("%Y%m%d")
def _default_expand_end() -> str:
"""Límite superior por defecto al expandir una serie sin rango explícito."""
return (datetime.now(timezone.utc) + timedelta(days=731)).strftime("%Y%m%d")
def _shift_iso_days(value: str, days: int) -> str:
"""Desplaza la parte de fecha de un ISO (``YYYY-MM-DD`` o con ``T...``).
Conserva intacta la parte horaria/offset (``T10:00:00+02:00``) y solo mueve
la fecha ``days`` días. Para una fecha pura mueve la fecha sola. Si el valor
no parsea, lo devuelve sin tocar (defensivo).
"""
if not value:
return value
date_part = value[:10]
rest = value[10:]
try:
base = datetime.strptime(date_part, "%Y-%m-%d")
except ValueError:
return value
shifted = (base + timedelta(days=days)).strftime("%Y-%m-%d")
return shifted + rest
def _occurrence_clone(event: dict, occ_ymd: str) -> dict:
"""Clona un evento maestro recurrente reubicado en la fecha ``occ_ymd``.
Mantiene la hora local / offset del maestro (solo cambia la fecha) y aplica el
mismo desplazamiento de días al ``dtend`` para preservar la duración. Marca
``occurrence=True`` cuando la fecha difiere de la del maestro (la primera
ocurrencia coincide con el maestro y queda ``occurrence=False``).
"""
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
new_date_iso = "%s-%s-%s" % (occ_ymd[:4], occ_ymd[4:6], occ_ymd[6:8])
clone = dict(event)
ds = event.get("dtstart") or ""
if event.get("all_day") or len(ds) == 10:
clone["dtstart"] = new_date_iso
else:
clone["dtstart"] = new_date_iso + ds[10:]
clone["dtstart_ical"] = occ_ymd
try:
delta = (
datetime.strptime(occ_ymd, "%Y%m%d")
- datetime.strptime(master_ymd, "%Y%m%d")
).days
except ValueError:
delta = 0
de = event.get("dtend")
if de:
clone["dtend"] = _shift_iso_days(de, delta)
de_ical = event.get("dtend_ical")
if de_ical:
clone["dtend_ical"] = (clone["dtend"] or "").replace("-", "")[:8]
clone["occurrence"] = occ_ymd != master_ymd
return clone
def _expand_event_occurrences(event: dict, dt_from: str, dt_to: str) -> list:
"""Expande un evento recurrente a sus ocurrencias dentro de ``[from, to]``.
Compone la función pura del registry ``expand_rrule`` (solo necesita las
FECHAS de cada ocurrencia; la hora local se preserva clonando el maestro). Si
el evento no tiene ``rrule``, o algo no parsea, devuelve ``[event]`` sin
tocar — nunca pierde el evento original.
"""
rrule = event.get("rrule")
if not rrule:
return [event]
master_ymd = (
event.get("dtstart_ical") or (event.get("dtstart") or "").replace("-", "")
)[:8]
if len(master_ymd) < 8:
return [event]
rs = (dt_from or "").replace("-", "")[:8] or _default_expand_start()
re_ = (dt_to or "").replace("-", "")[:8] or _default_expand_end()
try:
occ_dates = expand_rrule(master_ymd, rrule, rs, re_, all_day=True)
except Exception:
return [event]
if not occ_dates:
return []
return [_occurrence_clone(event, d) for d in occ_dates]
# ---------------------------------------------------------------------------
# Escritura de contactos: ficha .md del vault (fuente de verdad) + vCard
# ---------------------------------------------------------------------------
@@ -1534,6 +1681,25 @@ class EventIn(BaseModel):
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
# Regla de recurrencia iCalendar SIN el prefijo "RRULE:" (p.ej.
# "FREQ=WEEKLY;INTERVAL=1;COUNT=10"). None / "" → evento puntual. Editar un
# evento recurrente reescribe toda la serie (no se soporta editar una sola
# ocurrencia).
rrule: Optional[str] = None
class CalendarIn(BaseModel):
"""Cuerpo de POST /api/calendars: crea una colección de calendario nueva.
El ``slug`` es el segmento de URL de la colección (lo sanea la función del
registry ``dav_make_calendar`` a ``[a-z0-9_-]``). ``name`` es el nombre
visible; ``color`` un hex ``#rrggbb`` opcional.
"""
slug: str
name: Optional[str] = ""
color: Optional[str] = None
description: Optional[str] = None
def _parse_iso_input(value: str) -> Optional[dict]:
@@ -1701,6 +1867,13 @@ def _build_vcalendar(data: "EventIn", uid: str) -> str:
if data.color and data.color.strip():
# COLOR (RFC 7986) — nombre CSS3 o, para clientes Apple, el hex va aparte.
vevent.append("X-APPLE-CALENDAR-COLOR:%s" % data.color.strip())
rrule = (data.rrule or "").strip()
if rrule:
# Acepta tanto "FREQ=..." como "RRULE:FREQ=..."; normaliza a la línea
# canónica "RRULE:<cuerpo>" que entienden Xandikos y los clientes (DAVx5).
if rrule.upper().startswith("RRULE:"):
rrule = rrule[len("RRULE:"):].strip()
vevent.append("RRULE:%s" % rrule)
vevent.append("END:VEVENT")
body.append("\r\n".join(vevent))
body.append("END:VCALENDAR")
@@ -1898,6 +2071,22 @@ def create_app(vault_dir: str) -> FastAPI:
}
)
@app.post("/api/calendars")
def api_create_calendar(data: CalendarIn = Body(...)) -> JSONResponse:
"""Crea una colección de calendario nueva (MKCALENDAR + nombre/color).
Body: ``{slug, name?, color?, description?}``. Idempotente si ya existe.
Devuelve ``{status, href, existed?}``. 400 si falta el nombre; 503 si
Xandikos no responde.
"""
try:
res = state.create_calendar(data)
except (RuntimeError, DavUnavailable) as exc:
return JSONResponse(
status_code=503, content={"status": "error", "error": str(exc)}
)
return JSONResponse(status_code=201, content={"status": "ok", **res})
@app.get("/api/calendar")
def api_calendar(
cal: str = Query("", description="colección de calendario (ruta o nombre)"),