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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-12 00:40:59 +02:00
parent 43889bfc07
commit e792bc6e17
8 changed files with 2000 additions and 179 deletions
+272 -1
View File
@@ -230,7 +230,12 @@ def test_vevent_to_json_and_range():
assert len(events) == 1
evt = events[0]
assert evt["summary"] == "Reunión OSINT"
assert evt["dtstart"].startswith("20260615")
# Contrato nuevo: dtstart en ISO con offset (UTC -> +00:00) + tz original.
assert evt["dtstart"] == "2026-06-15T09:00:00+00:00"
assert evt["tz"] == "UTC"
assert evt["all_day"] is False
# dtstart_ical conserva el prefijo crudo para el filtro de rango.
assert evt["dtstart_ical"] == "20260615"
assert srv._event_in_range(evt, "20260601", "20260630") is True
assert srv._event_in_range(evt, "20260101", "20260131") is False
@@ -604,6 +609,272 @@ def test_crud_update_preserves_inherited_fields(crud_client, vault):
crud_client.delete("/api/contact/%s" % slug)
# ---------------------------------------------------------------------------
# Calendario: parseo de fechas (TZID / UTC / todo el día) + builder VCALENDAR
# ---------------------------------------------------------------------------
def test_vevent_tzid_se_normaliza_a_iso_con_offset():
"""Un DTSTART;TZID=Europe/Madrid sale como ISO con el offset de Madrid."""
vcal = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:tz-1\r\n"
"SUMMARY:Con zona\r\n"
"DTSTART;TZID=Europe/Madrid:20260615T100000\r\n"
"DTEND;TZID=Europe/Madrid:20260615T110000\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
evt = srv._vcalendar_to_events(vcal)[0]
# Junio: Madrid está en CEST (+02:00).
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["dtend"] == "2026-06-15T11:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
assert evt["all_day"] is False
def test_vevent_all_day_value_date():
"""Un DTSTART;VALUE=DATE:YYYYMMDD se marca all_day y sale como fecha ISO."""
vcal = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:ad-1\r\n"
"SUMMARY:Todo el día\r\nDTSTART;VALUE=DATE:20260615\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n"
)
evt = srv._vcalendar_to_events(vcal)[0]
assert evt["all_day"] is True
assert evt["dtstart"] == "2026-06-15"
assert evt["dtstart_ical"] == "20260615"
def test_build_vcalendar_tzid():
"""El builder emite DTSTART;TZID + un VTIMEZONE para una zona con DST."""
data = srv.EventIn(
cal="",
summary="Reunión, con coma",
dtstart="2026-06-15T10:00",
dtend="2026-06-15T11:00",
tz="Europe/Madrid",
all_day=False,
location="Oficina; sala 2",
)
vcal = srv._build_vcalendar(data, "uid-abc")
assert "BEGIN:VEVENT" in vcal
assert "UID:uid-abc" in vcal
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in vcal
assert "DTEND;TZID=Europe/Madrid:20260615T110000" in vcal
assert "BEGIN:VTIMEZONE" in vcal and "TZID:Europe/Madrid" in vcal
# El summary/location se escapan (coma y punto y coma).
assert "SUMMARY:Reunión\\, con coma" in vcal
assert "LOCATION:Oficina\\; sala 2" in vcal
# Round-trip: parsear lo construido reproduce la hora local de Madrid.
evt = srv._vcalendar_to_events(vcal)[0]
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
def test_build_vcalendar_all_day():
data = srv.EventIn(summary="Festivo", dtstart="2026-06-15", all_day=True)
vcal = srv._build_vcalendar(data, "uid-ad")
assert "DTSTART;VALUE=DATE:20260615" in vcal
assert "BEGIN:VTIMEZONE" not in vcal # all-day no necesita VTIMEZONE
def test_build_vcalendar_con_offset_va_a_utc():
"""Una entrada con offset explícito se convierte a UTC (...Z)."""
data = srv.EventIn(
summary="Con offset", dtstart="2026-06-15T10:00:00+02:00", tz="Europe/Madrid"
)
vcal = srv._build_vcalendar(data, "uid-off")
# 10:00+02:00 == 08:00 UTC.
assert "DTSTART:20260615T080000Z" in vcal
def test_build_vcalendar_fecha_invalida_lanza():
data = srv.EventIn(summary="Mala", dtstart="no-es-fecha")
with pytest.raises(ValueError):
srv._build_vcalendar(data, "uid-x")
def test_safe_event_resource_coincide_con_caldav_put():
"""El nombre .ics del DELETE coincide con el que deriva caldav_put_event."""
uid = "abc/def:ghi 123"
assert srv._safe_event_resource(uid) == "abc_def_ghi_123.ics"
# ---------------------------------------------------------------------------
# Calendario: listado de colecciones + CRUD de eventos (DAV mockeado)
# ---------------------------------------------------------------------------
@pytest.fixture()
def cal_client(vault, monkeypatch, tmp_path):
"""Cliente con el PUT/DELETE/list/get de CalDAV mockeado (CRUD sin red).
Registra los PUT/DELETE intentados y simula un servidor con una colección de
calendario y un store de eventos en memoria (para que el GET tras crear vea
el evento). ``calls`` expone lo que el server intentó.
"""
calls = {"put": [], "delete": []}
# Store en memoria: resource_name -> VCALENDAR text.
store = {}
ctag = {"v": "cal-v1"}
def _put_event(base, user, pw, coll, uid, vcal, **kw):
calls["put"].append({"uid": uid, "vcal": vcal, "coll": coll})
store[srv._safe_event_resource(uid)] = vcal
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
return {"status": "ok", "http_status": 201, "url": coll + uid + ".ics"}
def _delete(base, user, pw, resource_path, **kw):
calls["delete"].append(resource_path)
name = resource_path.rstrip("/").rsplit("/", 1)[-1]
existed = store.pop(name, None)
ctag["v"] = "cal-" + str(len(calls["put"]) + len(calls["delete"]))
if existed is None:
return {"status": "error", "http_status": 404, "error": "http 404"}
return {"status": "ok", "http_status": 204, "url": resource_path}
def _get_collection(base, user, pw, collection, content_type="ical", **kw):
res = [
{"href": collection + name, "etag": '"%s"' % name, "data": data}
for name, data in store.items()
]
return {"status": "ok", "http_status": 207, "resources": res}
def _ctag(base, user, pw, collection, **kw):
return {"status": "ok", "http_status": 207, "ctag": ctag["v"]}
def _list_calendars(base, user, pw, home, **kw):
return {
"status": "ok",
"http_status": 207,
"calendars": [
{
"href": "/enmanuel/calendars/calendar/",
"name": "calendar",
"color": None,
},
{
"href": "/enmanuel/calendars/trabajo/",
"name": "Trabajo",
"color": "#FF2968FF",
},
],
}
monkeypatch.setattr(srv, "caldav_put_event", _put_event)
monkeypatch.setattr(srv, "dav_delete_resource", _delete)
monkeypatch.setattr(srv, "dav_get_collection", _get_collection)
monkeypatch.setattr(srv, "dav_collection_ctag", _ctag)
monkeypatch.setattr(srv, "dav_list_calendars", _list_calendars)
monkeypatch.setattr(srv, "pass_get_secret", lambda *a, **k: {"status": "ok", "value": "x"})
cache_dir = tmp_path / "cal_cache"
monkeypatch.setattr(srv, "_CALENDAR_CACHE_FILE", str(cache_dir / "calendar.json"))
monkeypatch.setattr(srv, "_CACHE_DIR", str(cache_dir))
app = srv.create_app(vault)
client = TestClient(app)
client._cal_calls = calls # type: ignore[attr-defined]
return client
def test_calendars_endpoint_lista_con_color(cal_client):
r = cal_client.get("/api/calendars")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok" and data["count"] == 2
by_name = {c["name"]: c for c in data["calendars"]}
assert by_name["Trabajo"]["color"] == "#FF2968FF"
assert by_name["calendar"]["color"] is None
def test_event_crud_full_cycle(cal_client):
"""Golden: crear → leer → editar → borrar un evento (VEVENT sobre CalDAV)."""
calls = cal_client._cal_calls
# -- CREATE --
body = {
"summary": "ZZ Test Event",
"dtstart": "2026-06-15T10:00",
"dtend": "2026-06-15T11:00",
"tz": "Europe/Madrid",
"location": "Sala",
"description": "Prueba CRUD",
}
r = cal_client.post("/api/event", json=body)
assert r.status_code == 201, r.text
uid = r.json()["uid"]
assert uid
assert calls["put"], "debió hacer PUT del VEVENT"
assert "SUMMARY:ZZ Test Event" in calls["put"][-1]["vcal"]
assert "DTSTART;TZID=Europe/Madrid:20260615T100000" in calls["put"][-1]["vcal"]
# -- READ (aparece en /api/calendar) --
cal_client.post("/api/refresh") # fuerza recarga desde el store mockeado
g = cal_client.get("/api/calendar", params={"from": "2026-06-01", "to": "2026-06-30"})
assert g.status_code == 200
uids = [e["uid"] for e in g.json()["events"]]
assert uid in uids
evt = next(e for e in g.json()["events"] if e["uid"] == uid)
assert evt["dtstart"] == "2026-06-15T10:00:00+02:00"
assert evt["tz"] == "Europe/Madrid"
# -- UPDATE --
body2 = dict(body)
body2["summary"] = "ZZ Test Event (editado)"
body2["dtstart"] = "2026-06-15T12:00"
ur = cal_client.put("/api/event/%s" % uid, json=body2)
assert ur.status_code == 200, ur.text
assert "SUMMARY:ZZ Test Event (editado)" in calls["put"][-1]["vcal"]
assert "20260615T120000" in calls["put"][-1]["vcal"]
# El UID se reutiliza (idempotente): mismo recurso.
assert calls["put"][-1]["uid"] == uid
# -- DELETE --
dr = cal_client.delete("/api/event/%s" % uid)
assert dr.status_code == 200, dr.text
assert dr.json()["deleted"] is True
assert any(uid in p for p in calls["delete"])
# Tras borrar, ya no aparece.
cal_client.post("/api/refresh")
g2 = cal_client.get("/api/calendar")
assert uid not in [e["uid"] for e in g2.json()["events"]]
def test_event_create_fecha_invalida_400(cal_client):
r = cal_client.post(
"/api/event", json={"summary": "X", "dtstart": "no-fecha"}
)
assert r.status_code == 400
def test_event_create_summary_vacio_400(cal_client):
r = cal_client.post(
"/api/event", json={"summary": " ", "dtstart": "2026-06-15T10:00"}
)
assert r.status_code == 400
def test_event_delete_idempotente_404_es_ok(cal_client):
"""Borrar un evento inexistente NO es error: Xandikos 404 → idempotente."""
r = cal_client.delete("/api/event/no-existe-uid")
assert r.status_code == 200
assert r.json()["deleted"] is True
def test_calendar_endpoint_degrada_sin_red(client, monkeypatch):
"""Sin Xandikos, /api/calendars y /api/calendar devuelven 503 claro."""
monkeypatch.setattr(
srv, "dav_list_calendars", lambda *a, **k: {"status": "error", "error": "sin red"}
)
monkeypatch.setattr(
srv, "dav_get_collection", lambda *a, **k: {"status": "error", "error": "sin red"}
)
monkeypatch.setattr(
srv, "dav_collection_ctag", lambda *a, **k: {"status": "error", "error": "sin red"}
)
client.app.state.vault._xandikos_password = "x"
assert client.get("/api/calendars").status_code == 503
assert client.get("/api/calendar").status_code == 503
# ---------------------------------------------------------------------------
# Smoke real opcional contra Xandikos (gateado, no corre en CI)
# ---------------------------------------------------------------------------