From e792bc6e1771ffd9f84e2e5419a1bc977cb7ea70 Mon Sep 17 00:00:00 2001 From: agent Date: Fri, 12 Jun 2026 00:40:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20vista=20mes/semana/d=C3=ADa,?= =?UTF-8?q?=20TZ,=20selector=20de=20calendario,=20colores=20y=20CRUD=20de?= =?UTF-8?q?=20eventos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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[/]: 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) --- app.md | 49 +- frontend/src/api.ts | 61 ++- frontend/src/calendar.ts | 152 ++++++ frontend/src/main.tsx | 8 +- frontend/src/views/CalendarView.tsx | 726 +++++++++++++++++++++++----- frontend/src/views/EventModal.tsx | 257 ++++++++++ server/main.py | 653 +++++++++++++++++++++++-- tests/test_server.py | 273 ++++++++++- 8 files changed, 2000 insertions(+), 179 deletions(-) create mode 100644 frontend/src/calendar.ts create mode 100644 frontend/src/views/EventModal.tsx diff --git a/app.md b/app.md index d5f65ce..9ffa80c 100644 --- a/app.md +++ b/app.md @@ -2,8 +2,8 @@ name: osint_web lang: py domain: osint -version: 0.1.0 -description: "App web local OSINT: explora el vault de Obsidian osint (grafo sigma.js, tablas por tipo, fichas con galería de attachments) y la agenda/calendario del servidor Xandikos (CardDAV/CalDAV). Backend FastAPI que orquesta los grupos obsidian y dav del registry; escucha solo en 127.0.0.1 (datos sensibles)." +version: 0.2.0 +description: "App web local OSINT: explora el vault de Obsidian osint (grafo sigma.js, tablas por tipo, fichas con galería de attachments), la agenda del servidor Xandikos (CardDAV) y un calendario completo (CalDAV) con vista mes/semana/día, zonas horarias, selector de calendario, colores y CRUD de eventos. Backend FastAPI que orquesta los grupos obsidian y dav del registry; escucha solo en 127.0.0.1 (datos sensibles)." tags: [osint, web, sigma, graph, mantine, dav, obsidian, vault, dashboard] uses_functions: - build_obsidian_graph_py_obsidian @@ -18,7 +18,9 @@ uses_functions: - search_obsidian_notes_py_obsidian - dav_get_collection_py_infra - dav_collection_ctag_py_infra + - dav_list_calendars_py_infra - carddav_put_vcard_py_infra + - caldav_put_event_py_infra - dav_delete_resource_py_infra - split_vcards_py_infra - pass_get_secret_py_infra @@ -57,13 +59,22 @@ primaria con `create_obsidian_note` / `update_obsidian_note` / inmediato (`carddav_put_vcard` / `dav_delete_resource`) para que se vea ya en la app y en el móvil sin esperar al sync periódico del dag_engine. +Calendario (CRUD de eventos): la app lista las colecciones de calendario, muestra +los eventos en vista mes/semana/día (con zona horaria y color seleccionables) y +permite crear, editar y borrar eventos. Aquí la **fuente de verdad es Xandikos +directamente** (el calendario NO existe en el vault): cada operación escribe el +VCALENDAR/VEVENT en la colección CalDAV (`caldav_put_event` / +`dav_delete_resource`) e invalida la caché de esa colección. + Registry-first: el backend NO parsea el vault ni habla DAV a mano — orquesta las funciones del grupo `obsidian` (`build_obsidian_graph`, `read_obsidian_note`, `create_obsidian_note`, `update_obsidian_note`, `delete_obsidian_note`, `resolve_obsidian_embed`, ...) y del grupo `dav` (`dav_get_collection`, -`dav_collection_ctag`, `carddav_put_vcard`, `dav_delete_resource`, -`split_vcards`) más `pass_get_secret` para la credencial, todas declaradas en -`uses_functions`. +`dav_collection_ctag`, `dav_list_calendars`, `carddav_put_vcard`, +`caldav_put_event`, `dav_delete_resource`, `split_vcards`) más `pass_get_secret` +para la credencial, todas declaradas en `uses_functions`. La única lógica propia +de la app sobre el calendario es el parseo robusto de VEVENT (TZID / UTC / todo el +día → ISO con offset, vía `zoneinfo`) y la construcción del VCALENDAR de salida. ## Stack @@ -95,7 +106,12 @@ no se cachean (se piden a Xandikos en cada llamada). | GET | `/api/search?q=...` | nodos cuyo contenido matchea la query | | GET | `/api/contacts` | contactos del addressbook Xandikos (CardDAV) a JSON | | GET | `/api/contact/` | un vCard concreto a JSON | -| GET | `/api/calendar?from=&to=` | eventos del calendario Xandikos (CalDAV) en el rango | +| POST/PUT/DELETE | `/api/contact[/]` | CRUD de contactos (ficha `.md` del vault + reflejo vCard) | +| GET | `/api/calendars` | colecciones de calendario bajo `/enmanuel/calendars/` (nombre + color) | +| GET | `/api/calendar?cal=&from=&to=` | eventos de una colección del calendario (CalDAV) en el rango | +| POST | `/api/event` | crea un VEVENT (`{cal, summary, dtstart, dtend, tz, all_day, location, description, color}`) | +| PUT | `/api/event/` | edita un VEVENT existente | +| DELETE | `/api/event/?cal=` | borra un VEVENT | | POST | `/api/refresh` | re-escanea el vault y reconstruye la caché | ## Configuración Xandikos @@ -134,12 +150,19 @@ parseo vCard/iCalendar a JSON y degradación de los endpoints DAV sin red. ## Estado / pendiente -- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) con - 13 tests verdes. -- **Pendiente (siguiente agente)**: `frontend/` React + Vite + Mantine v9 + - `@fn_library` con sigma.js + graphology (GraphView, TablesView, NodeCard, - ContactsView, CalendarView). Onboarding previsto: backend en 8470 + - `pnpm dev` en `frontend/` → abrir `http://127.0.0.1:5173`. Ver - `frontend/README.md`. +- **Hecho**: scaffold del sub-repo + backend FastAPI completo (vault + DAV) + + frontend React/Vite/Mantine v9 (GraphView, TablesView, ContactsView, + CalendarView). Suite backend verde (40 tests + 1 smoke gateado). +- **Calendario (v0.2.0)**: vista mes/semana/día, selector de calendario (con + color), selector de zona horaria, colores por evento y CRUD completo de + eventos (crear en un hueco, editar/borrar). Backend con parseo robusto de + TZID/UTC/todo-el-día y construcción de VCALENDAR (con VTIMEZONE para zonas con + DST). Verificado con un ciclo real crear→editar→borrar contra Xandikos (cero + residuo). Onboarding: backend en 8470 + `pnpm dev` en `frontend/` → abrir + `http://127.0.0.1:5173` → pestaña "Calendario". +- **Gaps conocidos**: MKCALENDAR (crear colección de calendario nueva) NO + implementado — Xandikos tiene hoy una sola colección y la UI no lo necesita; + documentado como pendiente. El frontend usa una rejilla Mantine propia (sin + `react-big-calendar`) para evitar fricción de peer-deps con React 19. - Cuando exista el manifest de sub-repos del project (issue 0171), añadir esta app a `projects/osint/subrepos.yaml`. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0ee59e6..897b023 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -123,10 +123,15 @@ export interface ContactsPayload { export interface CalendarEvent { uid: string | null; summary: string | null; + // dtstart/dtend en ISO con offset ("2026-06-15T10:00:00+02:00") o "YYYY-MM-DD" + // para eventos de todo el día. `tz` es el TZID original del evento. dtstart: string | null; dtend: string | null; + tz?: string | null; + all_day?: boolean; location: string | null; description: string | null; + color?: string | null; href?: string; etag?: string; } @@ -138,6 +143,41 @@ export interface CalendarPayload { error?: string; } +export interface CalendarCollection { + href: string; + name: string; + color: string | null; +} + +export interface CalendarsPayload { + status: string; + count?: number; + calendars?: CalendarCollection[]; + default?: string; + error?: string; +} + +// Cuerpo de POST/PUT de un evento del calendario (VEVENT). +export interface EventInput { + cal?: string | null; + summary: string; + dtstart: string; // ISO local "2026-06-15T10:00" o "2026-06-15" (all_day) + dtend?: string | null; + tz?: string | null; + all_day?: boolean; + location?: string | null; + description?: string | null; + color?: string | null; +} + +export interface EventWriteResult { + status: string; + uid: string; + cal?: string; + deleted?: boolean; + dav?: { status?: string; http_status?: number; error?: string }; +} + // --- Endpoints ------------------------------------------------------------ export const fetchGraph = () => getJSON("/graph"); @@ -216,14 +256,33 @@ export const deleteContact = (slug: string) => "DELETE", ); -export const fetchCalendar = (from = "", to = "") => { +export const fetchCalendars = () => getJSON("/calendars"); + +export const fetchCalendar = (cal = "", from = "", to = "") => { const qs = new URLSearchParams(); + if (cal) qs.set("cal", cal); if (from) qs.set("from", from); if (to) qs.set("to", to); const tail = qs.toString(); return getJSON(`/calendar${tail ? `?${tail}` : ""}`); }; +// --- CRUD de eventos del calendario (VEVENT sobre CalDAV) ------------------ + +export const createEvent = (data: EventInput) => + sendJSON("/event", "POST", data); + +export const updateEvent = (uid: string, data: EventInput) => + sendJSON(`/event/${encodeURIComponent(uid)}`, "PUT", data); + +export const deleteEvent = (uid: string, cal = "") => { + const qs = cal ? `?cal=${encodeURIComponent(cal)}` : ""; + return sendJSON( + `/event/${encodeURIComponent(uid)}${qs}`, + "DELETE", + ); +}; + export const refresh = () => fetch(`${BASE}/refresh`, { method: "POST" }).then((r) => { if (!r.ok) throw new Error(`refresh falló: HTTP ${r.status}`); diff --git a/frontend/src/calendar.ts b/frontend/src/calendar.ts new file mode 100644 index 0000000..717fe12 --- /dev/null +++ b/frontend/src/calendar.ts @@ -0,0 +1,152 @@ +// Helpers de calendario: zonas horarias, posicionado de eventos por hora y +// agrupado por día. El backend devuelve dtstart/dtend en ISO con offset +// ("2026-06-15T10:00:00+02:00") o "YYYY-MM-DD" para todo el día; aquí los +// reubicamos en la zona horaria que el usuario elige en el visor (no en la del +// evento) y los convertimos a posiciones de la rejilla semana/día. + +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import "dayjs/locale/es"; +import type { CalendarEvent } from "./api"; + +dayjs.extend(utc); +dayjs.extend(timezone); +// Etiquetas de mes/día en español + semana empezando en lunes. +dayjs.locale("es"); + +export { dayjs }; + +// Zonas horarias ofrecidas en el selector. Europe/Madrid es el default del +// dominio; el resto cubre los husos más habituales para mirar un evento en otra +// hora local sin tener que listarlas todas. +export const TIMEZONES: { value: string; label: string }[] = [ + { value: "Europe/Madrid", label: "Madrid (CET/CEST)" }, + { value: "UTC", label: "UTC" }, + { value: "Europe/London", label: "Londres" }, + { value: "Europe/Paris", label: "París" }, + { value: "America/New_York", label: "Nueva York" }, + { value: "America/Mexico_City", label: "Ciudad de México" }, + { value: "America/Bogota", label: "Bogotá" }, + { value: "America/Argentina/Buenos_Aires", label: "Buenos Aires" }, + { value: "America/Los_Angeles", label: "Los Ángeles" }, + { value: "Asia/Tokyo", label: "Tokio" }, +]; + +/** Zona horaria local del navegador, para inicializar el selector. */ +export function browserTz(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || "Europe/Madrid"; + } catch { + return "Europe/Madrid"; + } +} + +/** + * Instante del evento en la zona `tz` elegida por el visor. Un evento de todo el + * día se ancla al mediodía de su fecha en esa zona (evita que un offset lo + * desplace de día). Devuelve un objeto dayjs ya en `tz`. + */ +export function eventStart(ev: CalendarEvent, tz: string) { + if (!ev.dtstart) return null; + if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtstart)) { + return dayjs.tz(ev.dtstart + "T12:00:00", tz); + } + return dayjs(ev.dtstart).tz(tz); +} + +/** Instante de fin en la zona `tz`, o null si el evento no tiene dtend. */ +export function eventEnd(ev: CalendarEvent, tz: string) { + if (!ev.dtend) return null; + if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtend)) { + return dayjs.tz(ev.dtend + "T12:00:00", tz); + } + return dayjs(ev.dtend).tz(tz); +} + +/** Clave de día "YYYY-MM-DD" del evento en la zona `tz` (para agrupar). */ +export function eventDayKey(ev: CalendarEvent, tz: string): string { + const s = eventStart(ev, tz); + return s ? s.format("YYYY-MM-DD") : ""; +} + +export interface PositionedEvent { + ev: CalendarEvent; + topPct: number; // 0..100 dentro del día + heightPct: number; // alto mínimo garantizado + allDay: boolean; +} + +/** + * Posiciona los eventos de un día concreto en la rejilla 0..24h (porcentajes + * sobre la altura del día). Los de todo el día / sin hora se devuelven aparte + * (allDay=true) para pintarse en una franja superior. `dayKey` es "YYYY-MM-DD". + */ +export function positionEventsForDay( + events: CalendarEvent[], + dayKey: string, + tz: string, +): PositionedEvent[] { + const out: PositionedEvent[] = []; + for (const ev of events) { + if (eventDayKey(ev, tz) !== dayKey) continue; + const start = eventStart(ev, tz); + if (!start) continue; + if (ev.all_day || /^\d{4}-\d{2}-\d{2}$/.test(ev.dtstart ?? "")) { + out.push({ ev, topPct: 0, heightPct: 0, allDay: true }); + continue; + } + const end = eventEnd(ev, tz) ?? start.add(1, "hour"); + const startMin = start.hour() * 60 + start.minute(); + let endMin = end.hour() * 60 + end.minute(); + if (end.format("YYYY-MM-DD") !== dayKey) endMin = 24 * 60; // termina otro día + if (endMin <= startMin) endMin = startMin + 30; + const topPct = (startMin / (24 * 60)) * 100; + const heightPct = Math.max(((endMin - startMin) / (24 * 60)) * 100, 2.5); + out.push({ ev, topPct, heightPct, allDay: false }); + } + return out; +} + +/** Los 7 días (dayjs) de la semana que contiene `date`, empezando en lunes. */ +export function weekDays(date: dayjs.Dayjs): dayjs.Dayjs[] { + // dayjs: 0=domingo. Queremos lunes como primer día. + const dow = date.day(); + const offsetToMonday = (dow + 6) % 7; + const monday = date.subtract(offsetToMonday, "day").startOf("day"); + return Array.from({ length: 7 }, (_, i) => monday.add(i, "day")); +} + +/** Matriz de semanas (cada una 7 días) que cubre el mes de `date`. */ +export function monthMatrix(date: dayjs.Dayjs): dayjs.Dayjs[][] { + const first = date.startOf("month"); + const start = weekDays(first)[0]; + const weeks: dayjs.Dayjs[][] = []; + let cursor = start; + for (let w = 0; w < 6; w++) { + const row: dayjs.Dayjs[] = []; + for (let d = 0; d < 7; d++) { + row.push(cursor); + cursor = cursor.add(1, "day"); + } + weeks.push(row); + // Parar si ya cubrimos todo el mes y la siguiente fila empieza en otro mes. + if (cursor.month() !== date.month() && w >= 3) break; + } + return weeks; +} + +export const HOURS = Array.from({ length: 24 }, (_, h) => h); +export const WEEKDAY_LABELS = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"]; + +/** Color efectivo de un evento: el propio del VEVENT o el del calendario. */ +export function eventColor( + ev: CalendarEvent, + calColor: string | null | undefined, +): string { + // Apple usa #RRGGBBAA (8 hex); recortamos el alfa para CSS clásico. + const raw = ev.color || calColor || null; + if (!raw) return "#23bdfe"; // brand por defecto + if (/^#[0-9a-fA-F]{8}$/.test(raw)) return raw.slice(0, 7); + return raw; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c499b76..7c6c0b2 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,9 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { MantineProvider } from "@mantine/core"; +import { DatesProvider } from "@mantine/dates"; import { Notifications } from "@mantine/notifications"; +import "dayjs/locale/es"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "@mantine/notifications/styles.css"; @@ -12,8 +14,10 @@ import { App } from "./App"; createRoot(document.getElementById("root")!).render( - - + + + + , ); diff --git a/frontend/src/views/CalendarView.tsx b/frontend/src/views/CalendarView.tsx index 59a5397..4efda44 100644 --- a/frontend/src/views/CalendarView.tsx +++ b/frontend/src/views/CalendarView.tsx @@ -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([]); + const [calendars, setCalendars] = useState([]); + const [activeCal, setActiveCal] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(dayjs().format("YYYY-MM-DD")); - const [selectedDay, setSelectedDay] = useState(null); + const [view, setView] = useState("semana"); + const [tz, setTz] = useState(browserTz()); + // `cursor` es el día de referencia (la vista lo expande a su mes/semana/día). + const [cursor, setCursor] = useState(dayjs().format("YYYY-MM-DD")); + const [modal, setModal] = useState(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(); + 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(); 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 ( -
- -
- ); - } + 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 ( + {/* Panel izquierdo: navegación + selectores */} + + 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 ( - +
{day}
); }} /> + + v && setTz(v)} + searchable + comboboxProps={{ withinPortal: true }} + /> + - {events.length} eventos en total + {events.length} eventos · TZ {tz} - {selectedDay && ( - setSelectedDay(null)} - > - Ver todo el mes - - )}
- - - - - {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} +
+ setView(v as ViewMode)} + data={[ + { value: "mes", label: "Mes" }, + { value: "semana", label: "Semana" }, + { value: "dia", label: "Día" }, + ]} + /> + - {visibleDays.length === 0 && ( - Sin eventos en este periodo. - )} - - {visibleDays.map((day) => ( - - {!selectedDay && ( - - {dayLabel(day)} - - )} - {(byDay.get(day) ?? []) - .sort((a, b) => - (a.dtstart ?? "").localeCompare(b.dtstart ?? ""), - ) - .map((ev, i) => ( - - ))} - - ))} - - + + {loading ? ( +
+ +
+ ) : view === "mes" ? ( + { + setCursor(d); + setView("dia"); + }} + onEvent={openEdit} + /> + ) : view === "semana" ? ( + + ) : ( + + )} +
+ + setModal(null)} + onSaved={onSaved} + onDelete={onDelete} + /> ); } -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 ( - - - - - {ev.summary || "(sin título)"} - - {ev.location && ( - - - - {ev.location} - - - )} - {ev.description && ( - - {ev.description} - - )} + + 1 ? 640 : 0 }}> + {/* Columna de horas */} + + + {HOURS.map((h) => ( + + {String(h).padStart(2, "0")}:00 + + ))} - - - - {formatICalTime(ev.dtstart)} - - - - + + {/* 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 ( + + {/* Cabecera del día */} + + {WEEKDAY_LABELS[(day.day() + 6) % 7]} {day.format("D")} + + + {/* Franja de "todo el día" */} + {allDay.length > 0 && ( + + {allDay.map((p, i) => ( + onEvent(p.ev)} + compact + /> + ))} + + )} + + {/* Rejilla horaria con posicionado absoluto */} + + {HOURS.map((h) => ( + 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) => ( + { + 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", + }} + > + + {eventStart(p.ev, tz)?.format("HH:mm")} ·{" "} + {p.ev.summary || "(sin título)"} + + + ))} + + + ); + })} + + + ); +} + +// --- 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(); + 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 ( + + + {WEEKDAY_LABELS.map((l) => ( + + {l} + + ))} + + + {weeks.map((week, wi) => ( + + {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 ( + 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, + }} + > + + {day.format("D")} + + + {list.slice(0, 3).map((ev, i) => ( + onEvent(ev)} + compact + /> + ))} + {list.length > 3 && ( + + +{list.length - 3} más + + )} + + + ); + })} + + ))} + + + ); +} + +// 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 ( + { + 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)"} + ); } diff --git a/frontend/src/views/EventModal.tsx b/frontend/src/views/EventModal.tsx new file mode 100644 index 0000000..46abe19 --- /dev/null +++ b/frontend/src/views/EventModal.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState } from "react"; +import { + Button, + ColorInput, + Group, + Modal, + Select, + Stack, + Switch, + Text, + TextInput, + Textarea, +} from "@mantine/core"; +import { IconTrash } from "@tabler/icons-react"; +import { + createEvent, + updateEvent, + type CalendarCollection, + type EventInput, +} from "../api"; +import { TIMEZONES } 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 +// "YYYY-MM-DDTHH:mm" (o "YYYY-MM-DD" para todo el día). +export interface EventDraft { + mode: "new" | "edit"; + uid?: string; + summary: string; + dtstart: string; + dtend: string; + tz: string; + all_day: boolean; + location: string; + description: string; + color: string; + cal: string; +} + +export function EventModal({ + draft, + calendars, + onClose, + onSaved, + onDelete, +}: { + draft: EventDraft | null; + calendars: CalendarCollection[]; + onClose: () => void; + onSaved: (msg: string) => void; + onDelete: (uid: string) => void; +}) { + const [form, setForm] = useState(draft); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(null); + + useEffect(() => { + setForm(draft); + setErr(null); + }, [draft]); + + if (!form) return null; + + function set(key: K, value: EventDraft[K]) { + setForm((f) => (f ? { ...f, [key]: value } : f)); + } + + // Al alternar "todo el día" recorta/expande la parte horaria del valor. + function toggleAllDay(allDay: boolean) { + setForm((f) => { + if (!f) return f; + if (allDay) { + return { + ...f, + all_day: true, + dtstart: f.dtstart.slice(0, 10), + dtend: f.dtend.slice(0, 10), + }; + } + return { + ...f, + all_day: false, + dtstart: f.dtstart.length === 10 ? `${f.dtstart}T09:00` : f.dtstart, + dtend: f.dtend.length === 10 ? `${f.dtend}T10:00` : f.dtend, + }; + }); + } + + async function save() { + if (!form) return; + if (!form.summary.trim()) { + setErr("El título es obligatorio."); + return; + } + if (!form.dtstart) { + setErr("La fecha de inicio es obligatoria."); + return; + } + setSaving(true); + setErr(null); + const payload: EventInput = { + cal: form.cal || null, + summary: form.summary.trim(), + dtstart: form.dtstart, + dtend: form.dtend || null, + tz: form.all_day ? null : form.tz, + all_day: form.all_day, + location: form.location || null, + description: form.description || null, + color: form.color || null, + }; + try { + if (form.mode === "edit" && form.uid) { + await updateEvent(form.uid, payload); + onSaved("Evento actualizado."); + } else { + await createEvent(payload); + onSaved("Evento creado."); + } + } catch (e) { + setErr(String(e)); + } finally { + setSaving(false); + } + } + + // Inputs de fecha/hora nativos: el navegador los muestra en formato local + // (DD/MM/AAAA + 24h en es-ES) pero su `value` es ISO. Mantenemos ISO interno. + const dateType = form.all_day ? "date" : "datetime-local"; + + return ( + + + set("summary", e.currentTarget.value)} + required + data-autofocus + /> + + toggleAllDay(e.currentTarget.checked)} + /> + + + set("dtstart", e.currentTarget.value)} + required + /> + set("dtend", e.currentTarget.value)} + /> + + + {!form.all_day && ( + ({ value: c.href, label: c.name }))} + value={form.cal || null} + onChange={(v) => v && set("cal", v)} + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + /> + + set("color", v)} + format="hex" + swatches={[ + "#23bdfe", + "#16a34a", + "#dc2626", + "#f59e0b", + "#8b5cf6", + "#ec4899", + "#0891b2", + "#64748b", + ]} + /> + + set("location", e.currentTarget.value)} + /> + +