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)} + /> + +