da55e32a1a
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1954 lines
58 KiB
Markdown
1954 lines
58 KiB
Markdown
# Internal Calendar with CalDAV Sync — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Revive the dormant `events` DB table as the source of truth for calendar events, add CRUD REST API, rewire AI tools to write through internal DB, and build a FullCalendar month/week UI with a slide-over form — with best-effort push sync to CalDAV.
|
|
|
|
**Architecture:** Fable DB is source of truth; CalDAV push is fire-and-forget background tasks after each write. AI tools are always available (no `is_caldav_configured` gate). Two new columns (`caldav_uid`, `color`) are added to the existing `events` table via migration 0029.
|
|
|
|
**Tech Stack:** Python/SQLAlchemy (service + routes), Quart (blueprint), FullCalendar Vue 3 (`@fullcalendar/vue3`, `daygrid`, `timegrid`, `interaction`), Vue 3 SFC.
|
|
|
|
**Spec:** `docs/specs/2026-03-25-internal-calendar-design.md`
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| File | Action | Purpose |
|
|
|------|--------|---------|
|
|
| `alembic/versions/0029_add_calendar_columns.py` | Create | Migration: add `caldav_uid`, `color` to events table |
|
|
| `src/fabledassistant/models/event.py` | Modify | Add `caldav_uid`, `color` mapped columns; update `to_dict()` |
|
|
| `src/fabledassistant/services/caldav.py` | Modify | Add optional `uid` param to `create_event` for UID injection |
|
|
| `src/fabledassistant/services/events.py` | Create | CRUD service + CalDAV push coordination |
|
|
| `src/fabledassistant/routes/events.py` | Create | REST blueprint: GET/POST /api/events, GET/PATCH/DELETE /api/events/:id |
|
|
| `src/fabledassistant/app.py` | Modify | Register events blueprint |
|
|
| `src/fabledassistant/services/tools.py` | Modify | Rewire calendar tools to events.py; move to _CORE_TOOLS; remove gate |
|
|
| `frontend/src/api/client.ts` | Modify | Add `EventEntry` interface + 5 typed helpers |
|
|
| `frontend/package.json` | Modify | Add FullCalendar dependencies |
|
|
| `frontend/src/components/EventSlideOver.vue` | Create | Slide-over form for create/edit/delete |
|
|
| `frontend/src/views/CalendarView.vue` | Create | Month/week FullCalendar view |
|
|
| `frontend/src/router/index.ts` | Modify | Add `/calendar` route |
|
|
| `frontend/src/components/AppHeader.vue` | Modify | Add Calendar nav link |
|
|
| `frontend/src/App.vue` | Modify | Add `g`+`l` keyboard shortcut |
|
|
| `tests/test_events_service.py` | Create | Service unit tests (mocked DB) |
|
|
| `tests/test_events_routes.py` | Create | Route unit tests |
|
|
|
|
---
|
|
|
|
## Task 1: Migration 0029 — add caldav_uid and color columns
|
|
|
|
**Files:**
|
|
- Create: `alembic/versions/0029_add_calendar_columns.py`
|
|
|
|
- [ ] **Step 1: Write the migration**
|
|
|
|
```python
|
|
"""Add caldav_uid and color columns to events table."""
|
|
|
|
from alembic import op
|
|
|
|
revision = "0029"
|
|
down_revision = "0028"
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.execute("""
|
|
ALTER TABLE events
|
|
ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''
|
|
""")
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color")
|
|
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid")
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add alembic/versions/0029_add_calendar_columns.py
|
|
git commit -m "feat(calendar): migration 0029 — add caldav_uid and color to events"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Update Event model
|
|
|
|
**Files:**
|
|
- Modify: `src/fabledassistant/models/event.py`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/test_events_model.py`:
|
|
```python
|
|
def test_event_model_has_new_columns():
|
|
from fabledassistant.models.event import Event
|
|
cols = {c.key for c in Event.__table__.columns}
|
|
assert "caldav_uid" in cols
|
|
assert "color" in cols
|
|
|
|
|
|
def test_event_to_dict_includes_new_fields():
|
|
from fabledassistant.models.event import Event
|
|
from datetime import datetime, timezone
|
|
e = Event(
|
|
user_id=1, uid="test-uid", title="Test",
|
|
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
|
|
caldav_uid="sync-uid", color="#6366f1",
|
|
)
|
|
d = e.to_dict()
|
|
assert d["caldav_uid"] == "sync-uid"
|
|
assert d["color"] == "#6366f1"
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_model.py -v
|
|
```
|
|
Expected: FAIL with `AssertionError` (columns not yet present)
|
|
|
|
- [ ] **Step 3: Add the two columns to the Event class**
|
|
|
|
In `src/fabledassistant/models/event.py`, after the `location` column (line 27), add:
|
|
```python
|
|
caldav_uid: Mapped[str] = mapped_column(Text, default="")
|
|
color: Mapped[str] = mapped_column(Text, default="")
|
|
```
|
|
|
|
Update `to_dict()` to include both new fields (add after `"location": self.location,`):
|
|
```python
|
|
"caldav_uid": self.caldav_uid,
|
|
"color": self.color,
|
|
```
|
|
|
|
Also add `user_id` to `to_dict()` since routes will need it for ownership checks. After `"id": self.id,` add:
|
|
```python
|
|
"user_id": self.user_id,
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_model.py -v
|
|
```
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/fabledassistant/models/event.py tests/test_events_model.py
|
|
git commit -m "feat(calendar): add caldav_uid, color to Event model"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Patch caldav.py — optional uid parameter for create_event
|
|
|
|
**Files:**
|
|
- Modify: `src/fabledassistant/services/caldav.py:165-257`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `tests/test_events_model.py`:
|
|
```python
|
|
def test_caldav_create_event_accepts_uid_param():
|
|
"""caldav.create_event signature must accept an optional uid param."""
|
|
import inspect
|
|
from fabledassistant.services.caldav import create_event
|
|
sig = inspect.signature(create_event)
|
|
assert "uid" in sig.parameters
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_model.py::test_caldav_create_event_accepts_uid_param -v
|
|
```
|
|
Expected: FAIL
|
|
|
|
- [ ] **Step 3: Add optional uid parameter to caldav.create_event**
|
|
|
|
In `src/fabledassistant/services/caldav.py`, modify the `create_event` function signature (line 165) to add `uid: str | None = None` as the last positional parameter (before `**kwargs` if any, otherwise just append):
|
|
|
|
```python
|
|
async def create_event(
|
|
user_id: int,
|
|
title: str,
|
|
start: str,
|
|
end: str | None = None,
|
|
duration: int | None = None,
|
|
description: str | None = None,
|
|
location: str | None = None,
|
|
all_day: bool = False,
|
|
recurrence: str | None = None,
|
|
timezone: str | None = None,
|
|
reminder_minutes: int | None = None,
|
|
attendees: list[str] | None = None,
|
|
calendar_name: str | None = None,
|
|
uid: str | None = None,
|
|
) -> dict:
|
|
```
|
|
|
|
Then, after the line `event = icalendar.Event()` (around line 195), add before `event.add("summary", title)`:
|
|
```python
|
|
if uid:
|
|
# Remove auto-generated UID if the library added one, then inject ours
|
|
if "UID" in event:
|
|
del event["UID"]
|
|
event.add("uid", uid)
|
|
```
|
|
|
|
- [ ] **Step 4: Run test to verify it passes**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_model.py -v
|
|
```
|
|
Expected: PASS (both tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/fabledassistant/services/caldav.py tests/test_events_model.py
|
|
git commit -m "feat(calendar): add optional uid param to caldav.create_event"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Create services/events.py — CRUD + CalDAV push
|
|
|
|
**Files:**
|
|
- Create: `src/fabledassistant/services/events.py`
|
|
- Test: `tests/test_events_service.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/test_events_service.py`:
|
|
```python
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
def _make_mock_session():
|
|
mock_session = AsyncMock()
|
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
|
mock_session.add = MagicMock()
|
|
mock_session.commit = AsyncMock()
|
|
mock_session.refresh = AsyncMock()
|
|
return mock_session
|
|
|
|
|
|
def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting",
|
|
caldav_uid="", color=""):
|
|
e = MagicMock()
|
|
e.id = id
|
|
e.user_id = user_id
|
|
e.uid = uid
|
|
e.title = title
|
|
e.caldav_uid = caldav_uid
|
|
e.color = color
|
|
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
|
|
e.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc)
|
|
e.all_day = False
|
|
e.description = ""
|
|
e.location = ""
|
|
e.recurrence = None
|
|
e.project_id = None
|
|
e.to_dict.return_value = {
|
|
"id": id, "uid": uid, "title": title,
|
|
"caldav_uid": caldav_uid, "color": color,
|
|
}
|
|
return e
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_event_stores_to_db():
|
|
mock_session = _make_mock_session()
|
|
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
|
patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
|
|
mock_cls.return_value = mock_session
|
|
from fabledassistant.services.events import create_event
|
|
result = await create_event(
|
|
user_id=1,
|
|
title="Dentist",
|
|
start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc),
|
|
)
|
|
assert mock_session.add.called
|
|
assert mock_session.commit.called
|
|
# CalDAV push background task should be scheduled
|
|
assert mock_task.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_events_by_query_returns_ilike_results():
|
|
mock_event = _make_mock_event(title="Team Meeting")
|
|
mock_session = _make_mock_session()
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [mock_event]
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
|
|
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
|
mock_cls.return_value = mock_session
|
|
from fabledassistant.services.events import find_events_by_query
|
|
results = await find_events_by_query(user_id=1, query="meeting")
|
|
assert len(results) == 1
|
|
assert results[0].title == "Team Meeting"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_events_returns_events_in_range():
|
|
mock_event = _make_mock_event()
|
|
mock_session = _make_mock_session()
|
|
mock_result = MagicMock()
|
|
mock_result.scalars.return_value.all.return_value = [mock_event]
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
|
|
with patch("fabledassistant.services.events.async_session") as mock_cls:
|
|
mock_cls.return_value = mock_session
|
|
from fabledassistant.services.events import list_events
|
|
results = await list_events(
|
|
user_id=1,
|
|
date_from=datetime(2026, 3, 1, tzinfo=timezone.utc),
|
|
date_to=datetime(2026, 3, 31, tzinfo=timezone.utc),
|
|
)
|
|
assert len(results) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_event_fires_caldav_push_when_uid_set():
|
|
mock_event = _make_mock_event(caldav_uid="sync-uid")
|
|
mock_session = _make_mock_session()
|
|
mock_result = MagicMock()
|
|
# get_event query
|
|
mock_result.scalar_one_or_none.return_value = mock_event
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
|
|
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
|
patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
|
|
mock_cls.return_value = mock_session
|
|
from fabledassistant.services.events import delete_event
|
|
await delete_event(user_id=1, event_id=1)
|
|
# Push task fired because caldav_uid is set
|
|
assert mock_task.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_event_fires_caldav_push():
|
|
mock_event = _make_mock_event(caldav_uid="sync-uid")
|
|
mock_session = _make_mock_session()
|
|
mock_result = MagicMock()
|
|
mock_result.scalar_one_or_none.return_value = mock_event
|
|
mock_session.execute = AsyncMock(return_value=mock_result)
|
|
|
|
with patch("fabledassistant.services.events.async_session") as mock_cls, \
|
|
patch("fabledassistant.services.events.asyncio.create_task") as mock_task:
|
|
mock_cls.return_value = mock_session
|
|
from fabledassistant.services.events import update_event
|
|
await update_event(user_id=1, event_id=1, title="Updated Title")
|
|
assert mock_task.called
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_service.py -v
|
|
```
|
|
Expected: FAIL with `ModuleNotFoundError` (service doesn't exist yet)
|
|
|
|
- [ ] **Step 3: Implement services/events.py**
|
|
|
|
Create `src/fabledassistant/services/events.py`:
|
|
```python
|
|
"""Internal event store service with CalDAV push sync."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import and_, or_, select
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.event import Event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def create_event(
|
|
user_id: int,
|
|
title: str,
|
|
start_dt: datetime,
|
|
end_dt: datetime | None = None,
|
|
all_day: bool = False,
|
|
description: str = "",
|
|
location: str = "",
|
|
color: str = "",
|
|
recurrence: str | None = None,
|
|
project_id: int | None = None,
|
|
# CalDAV-only fields (not stored in DB, forwarded to push)
|
|
duration: int | None = None,
|
|
reminder_minutes: int | None = None,
|
|
attendees: list[str] | None = None,
|
|
calendar_name: str | None = None,
|
|
) -> Event:
|
|
"""Create an event in the DB, then fire a CalDAV push task."""
|
|
uid = str(uuid.uuid4())
|
|
async with async_session() as session:
|
|
event = Event(
|
|
user_id=user_id,
|
|
uid=uid,
|
|
title=title,
|
|
start_dt=start_dt,
|
|
end_dt=end_dt,
|
|
all_day=all_day,
|
|
description=description,
|
|
location=location,
|
|
color=color,
|
|
recurrence=recurrence,
|
|
project_id=project_id,
|
|
)
|
|
session.add(event)
|
|
await session.commit()
|
|
await session.refresh(event)
|
|
|
|
extra_fields = {
|
|
"duration": duration,
|
|
"reminder_minutes": reminder_minutes,
|
|
"attendees": attendees,
|
|
"calendar_name": calendar_name,
|
|
}
|
|
asyncio.create_task(_push_create(event, user_id, extra_fields))
|
|
return event
|
|
|
|
|
|
async def get_event(user_id: int, event_id: int) -> Event | None:
|
|
"""Return event owned by user_id, or None."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
async def list_events(
|
|
user_id: int,
|
|
date_from: datetime,
|
|
date_to: datetime,
|
|
) -> list[Event]:
|
|
"""List events for user_id within [date_from, date_to]."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.user_id == user_id,
|
|
Event.start_dt >= date_from,
|
|
Event.start_dt <= date_to,
|
|
).order_by(Event.start_dt)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def search_events(
|
|
user_id: int,
|
|
query: str,
|
|
days_ahead: int = 90,
|
|
) -> list[Event]:
|
|
"""Search events by keyword in title, description, or location."""
|
|
now = datetime.now(timezone.utc)
|
|
date_to = now + timedelta(days=days_ahead)
|
|
q = f"%{query}%"
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.user_id == user_id,
|
|
Event.start_dt >= now,
|
|
Event.start_dt <= date_to,
|
|
or_(
|
|
Event.title.ilike(q),
|
|
Event.description.ilike(q),
|
|
Event.location.ilike(q),
|
|
),
|
|
).order_by(Event.start_dt)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def update_event(user_id: int, event_id: int, **fields) -> Event | None:
|
|
"""Partial update. Returns updated event or None if not found."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
|
)
|
|
event = result.scalar_one_or_none()
|
|
if event is None:
|
|
return None
|
|
old_title = event.title # capture before mutation for CalDAV lookup
|
|
allowed = {"title", "start_dt", "end_dt", "all_day", "description",
|
|
"location", "color", "recurrence", "project_id"}
|
|
for key, value in fields.items():
|
|
if key in allowed and value is not None:
|
|
setattr(event, key, value)
|
|
await session.commit()
|
|
await session.refresh(event)
|
|
|
|
asyncio.create_task(_push_update(event, user_id, old_title=old_title))
|
|
return event
|
|
|
|
|
|
async def delete_event(user_id: int, event_id: int) -> None:
|
|
"""Delete event. Fires CalDAV delete push if caldav_uid is set."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event_id, Event.user_id == user_id)
|
|
)
|
|
event = result.scalar_one_or_none()
|
|
if event is None:
|
|
return
|
|
caldav_uid = event.caldav_uid
|
|
event_title = event.title # needed to find the event on CalDAV by title
|
|
await session.delete(event)
|
|
await session.commit()
|
|
|
|
if caldav_uid:
|
|
asyncio.create_task(_push_delete(caldav_uid, event_title, user_id))
|
|
|
|
|
|
async def find_events_by_query(user_id: int, query: str) -> list[Event]:
|
|
"""ILIKE search on title — used by AI update/delete tools."""
|
|
q = f"%{query}%"
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(
|
|
Event.user_id == user_id,
|
|
Event.title.ilike(q),
|
|
).order_by(Event.start_dt)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CalDAV push helpers (fire-and-forget)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _push_create(event: Event, user_id: int, extra: dict) -> None:
|
|
try:
|
|
from fabledassistant.services.caldav import (
|
|
create_event as caldav_create,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
await caldav_create(
|
|
user_id=user_id,
|
|
title=event.title,
|
|
start=event.start_dt.isoformat(),
|
|
end=event.end_dt.isoformat() if event.end_dt else None,
|
|
description=event.description or None,
|
|
location=event.location or None,
|
|
all_day=event.all_day,
|
|
recurrence=event.recurrence,
|
|
uid=event.uid,
|
|
duration=extra.get("duration"),
|
|
reminder_minutes=extra.get("reminder_minutes"),
|
|
attendees=extra.get("attendees"),
|
|
calendar_name=extra.get("calendar_name"),
|
|
)
|
|
# Mark as synced
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(Event).where(Event.id == event.id)
|
|
)
|
|
ev = result.scalar_one_or_none()
|
|
if ev:
|
|
ev.caldav_uid = event.uid
|
|
await session.commit()
|
|
except Exception:
|
|
logger.warning("CalDAV push (create) failed for event %d", event.id, exc_info=True)
|
|
|
|
|
|
async def _push_update(event: Event, user_id: int, old_title: str = "") -> None:
|
|
"""Push an update to CalDAV. Uses old_title to locate the event by its pre-rename SUMMARY."""
|
|
if not event.caldav_uid:
|
|
return
|
|
try:
|
|
from fabledassistant.services.caldav import (
|
|
update_event as caldav_update,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
# Use old_title so CalDAV can find the event even if the title was changed
|
|
query_title = old_title or event.title
|
|
await caldav_update(
|
|
user_id=user_id,
|
|
query=query_title,
|
|
title=event.title,
|
|
start=event.start_dt.isoformat(),
|
|
end=event.end_dt.isoformat() if event.end_dt else None,
|
|
description=event.description or None,
|
|
location=event.location or None,
|
|
)
|
|
except Exception:
|
|
logger.warning("CalDAV push (update) failed for event %d", event.id, exc_info=True)
|
|
|
|
|
|
async def _push_delete(caldav_uid: str, event_title: str, user_id: int) -> None:
|
|
"""Push a delete to CalDAV. Uses event_title to locate the event by SUMMARY."""
|
|
try:
|
|
from fabledassistant.services.caldav import (
|
|
delete_event as caldav_delete,
|
|
is_caldav_configured,
|
|
)
|
|
if not await is_caldav_configured(user_id):
|
|
return
|
|
await caldav_delete(user_id=user_id, query=event_title)
|
|
except Exception:
|
|
logger.warning("CalDAV push (delete) failed for uid %s", caldav_uid, exc_info=True)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_service.py -v
|
|
```
|
|
Expected: PASS (all 5 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/fabledassistant/services/events.py tests/test_events_service.py
|
|
git commit -m "feat(calendar): implement events service with CalDAV push"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create routes/events.py + register in app.py
|
|
|
|
**Files:**
|
|
- Create: `src/fabledassistant/routes/events.py`
|
|
- Modify: `src/fabledassistant/app.py`
|
|
- Test: `tests/test_events_routes.py`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `tests/test_events_routes.py`:
|
|
```python
|
|
import pytest
|
|
from unittest.mock import AsyncMock, patch, MagicMock
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
def _make_mock_event():
|
|
e = MagicMock()
|
|
e.id = 1
|
|
e.user_id = 1
|
|
e.uid = "uid-abc"
|
|
e.title = "Meeting"
|
|
e.caldav_uid = ""
|
|
e.color = ""
|
|
e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc)
|
|
e.end_dt = None
|
|
e.all_day = False
|
|
e.description = ""
|
|
e.location = ""
|
|
e.recurrence = None
|
|
e.project_id = None
|
|
e.created_at = datetime(2026, 3, 25, tzinfo=timezone.utc)
|
|
e.updated_at = datetime(2026, 3, 25, tzinfo=timezone.utc)
|
|
e.to_dict.return_value = {"id": 1, "title": "Meeting", "user_id": 1}
|
|
return e
|
|
|
|
|
|
def test_events_blueprint_registered():
|
|
"""events_bp must be importable from its module."""
|
|
from fabledassistant.routes.events import events_bp
|
|
assert events_bp.name == "events"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_events_requires_from_and_to():
|
|
"""GET /api/events without from/to returns 400."""
|
|
from fabledassistant.app import create_app
|
|
app = create_app()
|
|
async with app.test_client() as client:
|
|
with patch("fabledassistant.routes.events.login_required", lambda f: f), \
|
|
patch("fabledassistant.routes.events._get_current_user_id", return_value=1):
|
|
response = await client.get("/api/events")
|
|
assert response.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_event_404_for_nonowner():
|
|
"""GET /api/events/:id returns 404 when owned by different user."""
|
|
from fabledassistant.app import create_app
|
|
app = create_app()
|
|
async with app.test_client() as client:
|
|
with patch("fabledassistant.routes.events.login_required", lambda f: f), \
|
|
patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \
|
|
patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = None # service returns None for non-owner
|
|
response = await client.get("/api/events/1")
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_event_404_for_nonowner():
|
|
"""PATCH /api/events/:id returns 404 for non-owner."""
|
|
from fabledassistant.app import create_app
|
|
app = create_app()
|
|
async with app.test_client() as client:
|
|
with patch("fabledassistant.routes.events.login_required", lambda f: f), \
|
|
patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \
|
|
patch("fabledassistant.routes.events.events_svc.update_event", new_callable=AsyncMock) as mock_upd:
|
|
mock_upd.return_value = None # service returns None for non-owner
|
|
response = await client.patch("/api/events/1", json={"title": "New"})
|
|
assert response.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_event_404_for_nonowner():
|
|
"""DELETE /api/events/:id returns 404 for non-owner."""
|
|
from fabledassistant.app import create_app
|
|
app = create_app()
|
|
async with app.test_client() as client:
|
|
with patch("fabledassistant.routes.events.login_required", lambda f: f), \
|
|
patch("fabledassistant.routes.events._get_current_user_id", return_value=2), \
|
|
patch("fabledassistant.routes.events.events_svc.get_event", new_callable=AsyncMock) as mock_get:
|
|
mock_get.return_value = None # service returns None for non-owner
|
|
response = await client.delete("/api/events/1")
|
|
assert response.status_code == 404
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_routes.py::test_events_blueprint_registered -v
|
|
```
|
|
Expected: FAIL with ImportError
|
|
|
|
- [ ] **Step 3: Implement routes/events.py**
|
|
|
|
Create `src/fabledassistant/routes/events.py`:
|
|
```python
|
|
"""Calendar events REST API."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from quart import Blueprint, g, jsonify, request
|
|
|
|
from fabledassistant.auth import login_required
|
|
import fabledassistant.services.events as events_svc
|
|
|
|
events_bp = Blueprint("events", __name__, url_prefix="/api/events")
|
|
|
|
|
|
def _get_current_user_id() -> int:
|
|
return g.user_id
|
|
|
|
|
|
@events_bp.get("")
|
|
@login_required
|
|
async def list_events():
|
|
date_from_str = request.args.get("from")
|
|
date_to_str = request.args.get("to")
|
|
if not date_from_str or not date_to_str:
|
|
return jsonify({"error": "from and to query params are required"}), 400
|
|
try:
|
|
date_from = datetime.fromisoformat(date_from_str)
|
|
date_to = datetime.fromisoformat(date_to_str)
|
|
except ValueError:
|
|
return jsonify({"error": "Invalid datetime format"}), 400
|
|
events = await events_svc.list_events(
|
|
user_id=_get_current_user_id(),
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
)
|
|
return jsonify([e.to_dict() for e in events])
|
|
|
|
|
|
@events_bp.post("")
|
|
@login_required
|
|
async def create_event():
|
|
data = await request.get_json() or {}
|
|
if not data.get("title") or not data.get("start_dt"):
|
|
return jsonify({"error": "title and start_dt are required"}), 400
|
|
try:
|
|
start_dt = datetime.fromisoformat(data["start_dt"])
|
|
end_dt = datetime.fromisoformat(data["end_dt"]) if data.get("end_dt") else None
|
|
except ValueError:
|
|
return jsonify({"error": "Invalid datetime format"}), 400
|
|
event = await events_svc.create_event(
|
|
user_id=_get_current_user_id(),
|
|
title=data["title"],
|
|
start_dt=start_dt,
|
|
end_dt=end_dt,
|
|
all_day=data.get("all_day", False),
|
|
description=data.get("description", ""),
|
|
location=data.get("location", ""),
|
|
color=data.get("color", ""),
|
|
recurrence=data.get("recurrence"),
|
|
project_id=data.get("project_id"),
|
|
)
|
|
return jsonify(event.to_dict()), 201
|
|
|
|
|
|
@events_bp.get("/<int:event_id>")
|
|
@login_required
|
|
async def get_event(event_id: int):
|
|
event = await events_svc.get_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
return jsonify(event.to_dict())
|
|
|
|
|
|
@events_bp.patch("/<int:event_id>")
|
|
@login_required
|
|
async def update_event(event_id: int):
|
|
data = await request.get_json() or {}
|
|
fields: dict = {}
|
|
for str_field in ("title", "description", "location", "color", "recurrence"):
|
|
if str_field in data:
|
|
fields[str_field] = data[str_field]
|
|
for bool_field in ("all_day",):
|
|
if bool_field in data:
|
|
fields[bool_field] = data[bool_field]
|
|
for int_field in ("project_id",):
|
|
if int_field in data:
|
|
fields[int_field] = data[int_field]
|
|
for dt_field in ("start_dt", "end_dt"):
|
|
if dt_field in data and data[dt_field]:
|
|
try:
|
|
fields[dt_field] = datetime.fromisoformat(data[dt_field])
|
|
except ValueError:
|
|
return jsonify({"error": f"Invalid datetime for {dt_field}"}), 400
|
|
event = await events_svc.update_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
**fields,
|
|
)
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
return jsonify(event.to_dict())
|
|
|
|
|
|
@events_bp.delete("/<int:event_id>")
|
|
@login_required
|
|
async def delete_event(event_id: int):
|
|
event = await events_svc.get_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
if event is None:
|
|
return jsonify({"error": "Event not found"}), 404
|
|
await events_svc.delete_event(
|
|
user_id=_get_current_user_id(),
|
|
event_id=event_id,
|
|
)
|
|
return "", 204
|
|
```
|
|
|
|
- [ ] **Step 4: Register the blueprint in app.py**
|
|
|
|
In `src/fabledassistant/app.py`, add to the imports:
|
|
```python
|
|
from fabledassistant.routes.events import events_bp
|
|
```
|
|
And in `create_app()`, alongside the other `app.register_blueprint(...)` calls, add:
|
|
```python
|
|
app.register_blueprint(events_bp)
|
|
```
|
|
|
|
- [ ] **Step 5: Run tests**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_routes.py -v
|
|
```
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/fabledassistant/routes/events.py src/fabledassistant/app.py tests/test_events_routes.py
|
|
git commit -m "feat(calendar): events REST API blueprint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Rewire AI tools to events service
|
|
|
|
**Files:**
|
|
- Modify: `src/fabledassistant/services/tools.py`
|
|
|
|
The changes:
|
|
1. Replace the `caldav` imports for event functions with `events` service imports
|
|
2. Move `_CALDAV_TOOLS` contents (the 5 event tools) into `_CORE_TOOLS`
|
|
3. Remove the `list_calendars` tool from `_CALDAV_TOOLS` (CalDAV-only, leave it conditional)
|
|
4. Remove `is_caldav_configured` from imports (still imported for `list_calendars` gate)
|
|
5. Update `execute_tool` for `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Add to `tests/test_events_service.py`:
|
|
```python
|
|
@pytest.mark.asyncio
|
|
async def test_tools_calendar_always_available():
|
|
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""
|
|
with patch("fabledassistant.services.tools.is_caldav_configured", new_callable=AsyncMock) as mock_configured:
|
|
mock_configured.return_value = False
|
|
from fabledassistant.services.tools import get_tools_for_user
|
|
tools = await get_tools_for_user(user_id=1)
|
|
tool_names = {t["function"]["name"] for t in tools}
|
|
assert "create_event" in tool_names
|
|
assert "list_events" in tool_names
|
|
assert "search_events" in tool_names
|
|
assert "update_event" in tool_names
|
|
assert "delete_event" in tool_names
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_service.py::test_tools_calendar_always_available -v
|
|
```
|
|
Expected: FAIL (tools only present when caldav is configured)
|
|
|
|
- [ ] **Step 3: Rewire tools.py**
|
|
|
|
In `src/fabledassistant/services/tools.py`:
|
|
|
|
**A. Replace event-related caldav imports** (lines 9-17):
|
|
```python
|
|
from fabledassistant.services.caldav import (
|
|
is_caldav_configured,
|
|
list_calendars,
|
|
)
|
|
from fabledassistant.services.events import (
|
|
create_event as events_create_event,
|
|
list_events as events_list_events,
|
|
search_events as events_search_events,
|
|
update_event as events_update_event,
|
|
delete_event as events_delete_event,
|
|
find_events_by_query,
|
|
)
|
|
```
|
|
|
|
**B. Move the 5 event tool definitions** (currently in `_CALDAV_TOOLS` at lines 579-757) from `_CALDAV_TOOLS` into `_CORE_TOOLS` (append before the closing `]` of `_CORE_TOOLS`). Leave `list_calendars` alone in a renamed `_CALDAV_TOOLS` that only contains it:
|
|
|
|
```python
|
|
# CalDAV tools — only when CalDAV is configured (server listing)
|
|
_CALDAV_TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_calendars",
|
|
"description": "List all available calendars. Use this when the user asks which calendars they have.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
]
|
|
```
|
|
|
|
**C. Update `get_tools_for_user`** (lines 834-844) — the function body stays the same; `_CALDAV_TOOLS` now only contains `list_calendars` so the gate is still correct.
|
|
|
|
**D. Update `execute_tool` for the 5 event handlers** to call the `events_*` functions:
|
|
|
|
Replace the `create_event` block (around line 1225):
|
|
```python
|
|
elif tool_name == "create_event":
|
|
from fabledassistant.services.events import create_event as _create_ev
|
|
from datetime import datetime, timezone
|
|
start_str = arguments["start"]
|
|
end_str = arguments.get("end")
|
|
all_day = arguments.get("all_day", False)
|
|
tz_str = arguments.get("timezone")
|
|
|
|
def _parse_dt(s: str) -> datetime:
|
|
dt = datetime.fromisoformat(s)
|
|
if dt.tzinfo is None and tz_str:
|
|
from zoneinfo import ZoneInfo
|
|
dt = dt.replace(tzinfo=ZoneInfo(tz_str))
|
|
elif dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
start_dt = _parse_dt(start_str)
|
|
end_dt = _parse_dt(end_str) if end_str else None
|
|
event = await _create_ev(
|
|
user_id=user_id,
|
|
title=arguments.get("title", "Untitled Event"),
|
|
start_dt=start_dt,
|
|
end_dt=end_dt,
|
|
all_day=all_day,
|
|
description=arguments.get("description", ""),
|
|
location=arguments.get("location", ""),
|
|
color=arguments.get("color", ""),
|
|
recurrence=arguments.get("recurrence"),
|
|
duration=arguments.get("duration"),
|
|
reminder_minutes=arguments.get("reminder_minutes"),
|
|
attendees=arguments.get("attendees"),
|
|
calendar_name=arguments.get("calendar_name"),
|
|
)
|
|
return {"success": True, "type": "event", "data": event.to_dict()}
|
|
```
|
|
|
|
Replace the `list_events` block:
|
|
```python
|
|
elif tool_name == "list_events":
|
|
from fabledassistant.services.events import list_events as _list_ev
|
|
from datetime import datetime, timezone
|
|
dt_from = datetime.fromisoformat(arguments["date_from"])
|
|
dt_to = datetime.fromisoformat(arguments["date_to"])
|
|
if dt_from.tzinfo is None:
|
|
dt_from = dt_from.replace(tzinfo=timezone.utc)
|
|
if dt_to.tzinfo is None:
|
|
dt_to = dt_to.replace(tzinfo=timezone.utc)
|
|
events = await _list_ev(user_id=user_id, date_from=dt_from, date_to=dt_to)
|
|
return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}}
|
|
```
|
|
|
|
Replace `search_events`:
|
|
```python
|
|
elif tool_name == "search_events":
|
|
from fabledassistant.services.events import search_events as _search_ev
|
|
events = await _search_ev(user_id=user_id, query=arguments.get("query", ""))
|
|
return {"success": True, "type": "events", "data": {"count": len(events), "events": [e.to_dict() for e in events]}}
|
|
```
|
|
|
|
Replace `update_event`:
|
|
```python
|
|
elif tool_name == "update_event":
|
|
from fabledassistant.services.events import find_events_by_query as _find_ev, update_event as _upd_ev
|
|
query = arguments["query"]
|
|
matches = await _find_ev(user_id=user_id, query=query)
|
|
if not matches:
|
|
return {"success": False, "error": f"No event found matching '{query}'."}
|
|
if len(matches) > 3:
|
|
titles = [e.title for e in matches]
|
|
return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"}
|
|
event = matches[0]
|
|
from datetime import datetime, timezone
|
|
fields: dict = {}
|
|
for k in ("title", "description", "location", "color"):
|
|
if arguments.get(k) is not None:
|
|
fields[k] = arguments[k]
|
|
for dt_k, arg_k in (("start_dt", "start"), ("end_dt", "end")):
|
|
if arguments.get(arg_k):
|
|
dt = datetime.fromisoformat(arguments[arg_k])
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
fields[dt_k] = dt.astimezone(timezone.utc)
|
|
updated = await _upd_ev(user_id=user_id, event_id=event.id, **fields)
|
|
return {"success": True, "type": "event_updated", "data": updated.to_dict() if updated else {}}
|
|
```
|
|
|
|
Replace `delete_event`:
|
|
```python
|
|
elif tool_name == "delete_event":
|
|
from fabledassistant.services.events import find_events_by_query as _find_ev2, delete_event as _del_ev
|
|
query = arguments["query"]
|
|
matches = await _find_ev2(user_id=user_id, query=query)
|
|
if not matches:
|
|
return {"success": False, "error": f"No event found matching '{query}'."}
|
|
if len(matches) > 3:
|
|
titles = [e.title for e in matches]
|
|
return {"success": False, "error": f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}"}
|
|
event = matches[0]
|
|
await _del_ev(user_id=user_id, event_id=event.id)
|
|
return {"success": True, "type": "event_deleted", "data": {"id": event.id, "title": event.title}}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest tests/test_events_service.py -v
|
|
```
|
|
Expected: PASS (all tests including the new one)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/fabledassistant/services/tools.py tests/test_events_service.py
|
|
git commit -m "feat(calendar): rewire AI calendar tools to internal events service"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Frontend API helpers in client.ts
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/api/client.ts`
|
|
|
|
- [ ] **Step 1: Add EventEntry interface and 5 helpers**
|
|
|
|
At the end of `frontend/src/api/client.ts`, add:
|
|
|
|
```typescript
|
|
// ── Calendar Events ──────────────────────────────────────────────────────────
|
|
|
|
export interface EventEntry {
|
|
id: number;
|
|
uid: string;
|
|
title: string;
|
|
start_dt: string;
|
|
end_dt: string | null;
|
|
all_day: boolean;
|
|
description: string;
|
|
location: string;
|
|
color: string;
|
|
recurrence: string | null;
|
|
project_id: number | null;
|
|
caldav_uid: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface CreateEventBody {
|
|
title: string;
|
|
start_dt: string;
|
|
end_dt?: string | null;
|
|
all_day?: boolean;
|
|
description?: string;
|
|
location?: string;
|
|
color?: string;
|
|
recurrence?: string | null;
|
|
project_id?: number | null;
|
|
}
|
|
|
|
export function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
|
return apiGet(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
}
|
|
|
|
export function createEvent(body: CreateEventBody): Promise<EventEntry> {
|
|
return apiPost("/api/events", body);
|
|
}
|
|
|
|
export function getEvent(id: number): Promise<EventEntry> {
|
|
return apiGet(`/api/events/${id}`);
|
|
}
|
|
|
|
export function updateEvent(id: number, body: Partial<CreateEventBody>): Promise<EventEntry> {
|
|
return apiPatch(`/api/events/${id}`, body);
|
|
}
|
|
|
|
export function deleteEvent(id: number): Promise<void> {
|
|
return apiDelete(`/api/events/${id}`);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/api/client.ts
|
|
git commit -m "feat(calendar): add EventEntry types and API helpers to client.ts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Install FullCalendar packages
|
|
|
|
**Files:**
|
|
- Modify: `frontend/package.json`
|
|
|
|
- [ ] **Step 1: Install FullCalendar**
|
|
|
|
```bash
|
|
docker compose exec frontend npm install @fullcalendar/vue3 @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the packages appear in package.json**
|
|
|
|
```bash
|
|
grep fullcalendar frontend/package.json
|
|
```
|
|
Expected: 4 lines with `@fullcalendar/...`
|
|
|
|
- [ ] **Step 3: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/package.json frontend/package-lock.json
|
|
git commit -m "feat(calendar): add FullCalendar dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Create EventSlideOver.vue
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/EventSlideOver.vue`
|
|
|
|
- [ ] **Step 1: Create EventSlideOver.vue**
|
|
|
|
Create `frontend/src/components/EventSlideOver.vue`:
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed, onMounted } from "vue";
|
|
import {
|
|
createEvent,
|
|
updateEvent,
|
|
deleteEvent,
|
|
type EventEntry,
|
|
type CreateEventBody,
|
|
} from "@/api/client";
|
|
import { apiGet } from "@/api/client";
|
|
|
|
interface ProjectOption { id: number; title: string; }
|
|
|
|
interface Props {
|
|
modelValue: boolean;
|
|
event?: EventEntry | null;
|
|
defaultDate?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
event: null,
|
|
defaultDate: "",
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
(e: "update:modelValue", val: boolean): void;
|
|
(e: "saved"): void;
|
|
(e: "deleted"): void;
|
|
}>();
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit("update:modelValue", v),
|
|
});
|
|
|
|
const isEdit = computed(() => !!props.event);
|
|
const saving = ref(false);
|
|
const deleting = ref(false);
|
|
const error = ref("");
|
|
const projects = ref<ProjectOption[]>([]);
|
|
|
|
async function loadProjects() {
|
|
try {
|
|
const data = await apiGet("/api/projects");
|
|
projects.value = (data.projects ?? data) as ProjectOption[];
|
|
} catch { /* non-blocking */ }
|
|
}
|
|
|
|
onMounted(loadProjects);
|
|
|
|
const form = ref<CreateEventBody & { project_id?: number | null }>({
|
|
title: "",
|
|
start_dt: "",
|
|
end_dt: "",
|
|
all_day: false,
|
|
description: "",
|
|
location: "",
|
|
color: "",
|
|
project_id: null,
|
|
});
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(open) => {
|
|
if (!open) return;
|
|
error.value = "";
|
|
if (props.event) {
|
|
form.value = {
|
|
title: props.event.title,
|
|
start_dt: toLocalInput(props.event.start_dt),
|
|
end_dt: props.event.end_dt ? toLocalInput(props.event.end_dt) : "",
|
|
all_day: props.event.all_day,
|
|
description: props.event.description,
|
|
location: props.event.location,
|
|
color: props.event.color,
|
|
project_id: props.event.project_id,
|
|
};
|
|
} else {
|
|
form.value = {
|
|
title: "",
|
|
start_dt: props.defaultDate ? `${props.defaultDate}T09:00` : "",
|
|
end_dt: "",
|
|
all_day: false,
|
|
description: "",
|
|
location: "",
|
|
color: "",
|
|
project_id: null,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
function toLocalInput(isoStr: string): string {
|
|
if (!isoStr) return "";
|
|
const d = new Date(isoStr);
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
function toIso(localStr: string): string {
|
|
if (!localStr) return "";
|
|
// Create a timezone-aware string from local input
|
|
return new Date(localStr).toISOString();
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!form.value.title.trim()) {
|
|
error.value = "Title is required.";
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
error.value = "";
|
|
try {
|
|
const body: CreateEventBody = {
|
|
title: form.value.title.trim(),
|
|
start_dt: toIso(form.value.start_dt as string),
|
|
end_dt: form.value.end_dt ? toIso(form.value.end_dt as string) : null,
|
|
all_day: form.value.all_day,
|
|
description: form.value.description,
|
|
location: form.value.location,
|
|
color: form.value.color,
|
|
project_id: form.value.project_id ?? null,
|
|
};
|
|
if (isEdit.value && props.event) {
|
|
await updateEvent(props.event.id, body);
|
|
} else {
|
|
await createEvent(body);
|
|
}
|
|
isOpen.value = false;
|
|
emit("saved");
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : "Failed to save event.";
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!props.event) return;
|
|
if (!confirm(`Delete "${props.event.title}"?`)) return;
|
|
deleting.value = true;
|
|
try {
|
|
await deleteEvent(props.event.id);
|
|
isOpen.value = false;
|
|
emit("deleted");
|
|
} catch (e: unknown) {
|
|
error.value = e instanceof Error ? e.message : "Failed to delete event.";
|
|
} finally {
|
|
deleting.value = false;
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") isOpen.value = false;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="slide-over">
|
|
<div
|
|
v-if="isOpen"
|
|
class="slide-over-backdrop"
|
|
@click.self="isOpen = false"
|
|
@keydown="handleKeydown"
|
|
>
|
|
<div class="slide-over-panel" role="dialog" aria-modal="true">
|
|
<header class="slide-over-header">
|
|
<h2 class="slide-over-title">{{ isEdit ? "Edit Event" : "New Event" }}</h2>
|
|
<button class="btn-icon" @click="isOpen = false" aria-label="Close">×</button>
|
|
</header>
|
|
|
|
<div class="slide-over-body">
|
|
<div v-if="error" class="slide-over-error">{{ error }}</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Title <span class="required">*</span></label>
|
|
<input
|
|
v-model="form.title"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="Event title"
|
|
autofocus
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label class="form-label">Start</label>
|
|
<input
|
|
v-model="form.start_dt"
|
|
:type="form.all_day ? 'date' : 'datetime-local'"
|
|
class="form-input"
|
|
/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">End</label>
|
|
<input
|
|
v-model="form.end_dt"
|
|
:type="form.all_day ? 'date' : 'datetime-local'"
|
|
class="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group form-check">
|
|
<input id="all-day" v-model="form.all_day" type="checkbox" />
|
|
<label for="all-day" class="form-check-label">All day</label>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Location</label>
|
|
<input
|
|
v-model="form.location"
|
|
type="text"
|
|
class="form-input"
|
|
placeholder="Optional location"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Description</label>
|
|
<textarea
|
|
v-model="form.description"
|
|
class="form-input"
|
|
rows="3"
|
|
placeholder="Optional description"
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">Colour</label>
|
|
<div class="color-picker-row">
|
|
<input v-model="form.color" type="color" class="color-swatch" />
|
|
<input
|
|
v-model="form.color"
|
|
type="text"
|
|
class="form-input color-hex"
|
|
placeholder="#6366f1"
|
|
maxlength="7"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group" v-if="projects.length > 0">
|
|
<label class="form-label">Project</label>
|
|
<select v-model="form.project_id" class="form-input">
|
|
<option :value="null">— none —</option>
|
|
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="slide-over-footer">
|
|
<button
|
|
v-if="isEdit"
|
|
class="btn btn-danger"
|
|
:disabled="deleting"
|
|
@click="handleDelete"
|
|
>
|
|
{{ deleting ? "Deleting..." : "Delete" }}
|
|
</button>
|
|
<div class="footer-right">
|
|
<button class="btn btn-ghost" @click="isOpen = false">Cancel</button>
|
|
<button class="btn btn-primary" :disabled="saving" @click="handleSave">
|
|
{{ saving ? "Saving..." : isEdit ? "Save Changes" : "Create Event" }}
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.slide-over-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
z-index: 500;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.slide-over-panel {
|
|
width: min(480px, 100vw);
|
|
height: 100%;
|
|
background: var(--color-bg-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.slide-over-header {
|
|
padding: 1.25rem 1.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
.slide-over-title {
|
|
font-family: Fraunces, serif;
|
|
font-size: 1.1rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.slide-over-body {
|
|
flex: 1;
|
|
padding: 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.slide-over-error {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
border-radius: 6px;
|
|
padding: 0.5rem 0.75rem;
|
|
color: #ef4444;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.slide-over-footer {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--color-border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.footer-right {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.form-label {
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.required {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.form-input {
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.5rem 0.75rem;
|
|
color: var(--color-text-primary);
|
|
font-size: 0.9rem;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.form-input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
|
|
.form-check {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.form-check-label {
|
|
font-size: 0.9rem;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.color-picker-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.color-swatch {
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
border: none;
|
|
padding: 0;
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.color-hex {
|
|
flex: 1;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: var(--radius-md);
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-ghost {
|
|
background: transparent;
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: #ef4444;
|
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
}
|
|
|
|
.slide-over-enter-active,
|
|
.slide-over-leave-active {
|
|
transition: transform 0.25s ease;
|
|
}
|
|
|
|
.slide-over-enter-from,
|
|
.slide-over-leave-to {
|
|
transform: translateX(100%);
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/EventSlideOver.vue
|
|
git commit -m "feat(calendar): EventSlideOver component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Create CalendarView.vue
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/views/CalendarView.vue`
|
|
|
|
- [ ] **Step 1: Create CalendarView.vue**
|
|
|
|
Create `frontend/src/views/CalendarView.vue`:
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from "vue";
|
|
import FullCalendar from "@fullcalendar/vue3";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import EventSlideOver from "@/components/EventSlideOver.vue";
|
|
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
|
import type { CalendarOptions, EventInput, EventDropArg, EventResizeDoneArg } from "@fullcalendar/core";
|
|
|
|
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
|
|
|
const slideOverOpen = ref(false);
|
|
const activeEvent = ref<EventEntry | null>(null);
|
|
const defaultDate = ref("");
|
|
|
|
function toCalendarEvent(e: EventEntry): EventInput {
|
|
return {
|
|
id: String(e.id),
|
|
title: e.title,
|
|
start: e.start_dt,
|
|
end: e.end_dt ?? undefined,
|
|
allDay: e.all_day,
|
|
backgroundColor: e.color || undefined,
|
|
borderColor: e.color || undefined,
|
|
extendedProps: { raw: e },
|
|
};
|
|
}
|
|
|
|
async function loadEvents(
|
|
fetchInfo: { start: Date; end: Date },
|
|
successCallback: (events: EventInput[]) => void,
|
|
failureCallback: (error: Error) => void
|
|
) {
|
|
try {
|
|
const events = await listEvents(
|
|
fetchInfo.start.toISOString(),
|
|
fetchInfo.end.toISOString()
|
|
);
|
|
successCallback(events.map(toCalendarEvent));
|
|
} catch (e) {
|
|
failureCallback(e instanceof Error ? e : new Error("Failed to load events"));
|
|
}
|
|
}
|
|
|
|
function handleDateClick(info: { dateStr: string }) {
|
|
activeEvent.value = null;
|
|
defaultDate.value = info.dateStr.substring(0, 10);
|
|
slideOverOpen.value = true;
|
|
}
|
|
|
|
function handleEventClick(info: { event: { id: string; extendedProps: { raw: EventEntry } } }) {
|
|
activeEvent.value = info.event.extendedProps.raw;
|
|
defaultDate.value = "";
|
|
slideOverOpen.value = true;
|
|
}
|
|
|
|
async function handleEventDrop(info: EventDropArg) {
|
|
const raw = info.event.extendedProps.raw as EventEntry;
|
|
try {
|
|
await updateEvent(raw.id, {
|
|
start_dt: info.event.start!.toISOString(),
|
|
end_dt: info.event.end?.toISOString() ?? null,
|
|
});
|
|
} catch {
|
|
info.revert();
|
|
}
|
|
}
|
|
|
|
async function handleEventResize(info: EventResizeDoneArg) {
|
|
const raw = info.event.extendedProps.raw as EventEntry;
|
|
try {
|
|
await updateEvent(raw.id, {
|
|
start_dt: info.event.start!.toISOString(),
|
|
end_dt: info.event.end?.toISOString() ?? null,
|
|
});
|
|
} catch {
|
|
info.revert();
|
|
}
|
|
}
|
|
|
|
function handleSaved() {
|
|
calendarRef.value?.getApi().refetchEvents();
|
|
}
|
|
|
|
function handleDeleted() {
|
|
calendarRef.value?.getApi().refetchEvents();
|
|
}
|
|
|
|
const calendarOptions: CalendarOptions = {
|
|
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
|
initialView: "dayGridMonth",
|
|
timeZone: "local",
|
|
headerToolbar: {
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "dayGridMonth,timeGridWeek",
|
|
},
|
|
events: loadEvents,
|
|
editable: true,
|
|
selectable: true,
|
|
dateClick: handleDateClick,
|
|
eventClick: handleEventClick,
|
|
eventDrop: handleEventDrop,
|
|
eventResize: handleEventResize,
|
|
height: "auto",
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="calendar-page">
|
|
<div class="calendar-header">
|
|
<h1 class="calendar-title">Calendar</h1>
|
|
</div>
|
|
<div class="calendar-wrapper">
|
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
|
</div>
|
|
|
|
<EventSlideOver
|
|
v-model="slideOverOpen"
|
|
:event="activeEvent"
|
|
:default-date="defaultDate"
|
|
@saved="handleSaved"
|
|
@deleted="handleDeleted"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.calendar-page {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.calendar-header {
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
.calendar-title {
|
|
font-family: Fraunces, serif;
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.calendar-wrapper {
|
|
background: var(--color-bg-secondary);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1rem;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* FullCalendar theme overrides */
|
|
:deep(.fc) {
|
|
--fc-border-color: var(--color-border);
|
|
--fc-button-bg-color: var(--color-bg-tertiary);
|
|
--fc-button-border-color: var(--color-border);
|
|
--fc-button-hover-bg-color: var(--color-primary);
|
|
--fc-button-hover-border-color: var(--color-primary);
|
|
--fc-button-active-bg-color: var(--color-primary);
|
|
--fc-button-active-border-color: var(--color-primary);
|
|
--fc-today-bg-color: rgba(99, 102, 241, 0.08);
|
|
--fc-page-bg-color: transparent;
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
:deep(.fc-col-header-cell-cushion),
|
|
:deep(.fc-daygrid-day-number) {
|
|
color: var(--color-text-primary);
|
|
}
|
|
|
|
:deep(.fc-event) {
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
}
|
|
</style>
|
|
```
|
|
|
|
- [ ] **Step 2: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/views/CalendarView.vue
|
|
git commit -m "feat(calendar): CalendarView with FullCalendar month/week grid"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Wire up route, nav link, and keyboard shortcut
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/router/index.ts`
|
|
- Modify: `frontend/src/components/AppHeader.vue`
|
|
- Modify: `frontend/src/App.vue`
|
|
|
|
- [ ] **Step 1: Add /calendar route**
|
|
|
|
In `frontend/src/router/index.ts`, after the `/briefing` route block (around line 113), add:
|
|
```typescript
|
|
{
|
|
path: "/calendar",
|
|
name: "calendar",
|
|
component: () => import("@/views/CalendarView.vue"),
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 2: Add Calendar nav link to AppHeader**
|
|
|
|
In `frontend/src/components/AppHeader.vue`, in the desktop nav center (after the Briefing link, line 80):
|
|
```html
|
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
```
|
|
|
|
In the mobile menu (after line 129):
|
|
```html
|
|
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
|
```
|
|
|
|
- [ ] **Step 3: Add g+l keyboard shortcut in App.vue**
|
|
|
|
In `frontend/src/App.vue`, in the `g+X` switch statement (around line 84), add after `case "g": ...`:
|
|
```typescript
|
|
case "l": router.push("/calendar"); break;
|
|
```
|
|
|
|
- [ ] **Step 4: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
|
|
git commit -m "feat(calendar): add /calendar route, nav link, g+l shortcut"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Full test run and final commit
|
|
|
|
- [ ] **Step 1: Run the full backend test suite**
|
|
|
|
```bash
|
|
docker compose exec app python -m pytest -v
|
|
```
|
|
Expected: all tests pass
|
|
|
|
- [ ] **Step 2: Run typecheck**
|
|
|
|
```bash
|
|
docker compose exec frontend npx vue-tsc --noEmit
|
|
```
|
|
Expected: no errors
|
|
|
|
- [ ] **Step 3: Final commit if any cleanup needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(calendar): internal calendar with CalDAV sync — complete"
|
|
```
|