# JARVIS Nextcloud Calendar Integration 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:** Give JARVIS read/write access to the Nextcloud "FFW-Onza-Alle" CalDAV calendar: a dashboard widget listing upcoming events, and chat-driven querying + confirmed event creation via Claude tool use. **Architecture:** A `caldav`-backed helper pair (`list_upcoming_events`, `create_event`) wrapped in `asyncio.to_thread` (same pattern as the existing Postgres `db_query` helper). A new protected `GET /api/v1/calendar/events` endpoint serves the dashboard widget. `/api/v1/chat` gains two Claude tools that call the same helpers, with a system-prompt rule requiring explicit user confirmation before any write. **Tech Stack:** `caldav` + `icalendar` (Python, new dependencies), FastAPI (existing `require_admin_key`/`db_query` patterns), Anthropic Messages API tool use, React (existing `apiFetch` pattern). **Spec:** `docs/superpowers/specs/2026-09-13-calendar-integration-design.md` ## Global Constraints - No dedicated git repository for this project — every "Commit" step is replaced by "confirm the file is saved". - SSH: `ssh -F /dev/null -o IdentitiesOnly=yes -i ~/.ssh/jarvis_core_key jarvis-core@72.61.186.98`. Backend files: `Claude outputs/`. Frontend files: `web/`. - CalDAV connectivity is already verified working (13.09.2026): read found 11 real events, a test event was created and deleted successfully using the exact credentials below. - Credentials (already provided, put in `.env` — never hardcode in source): `NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/`, `NEXTCLOUD_USER=jonny`, `NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ`. - The DAV client root is derived from the calendar URL by splitting on `/calendars/`: `dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/"` — this produces `https://cloud.ffw-onza.de/remote.php/dav/`, verified against the real server. - An all-day event's `dtstart` is a plain `date`, not `datetime` — `isinstance(dtstart, datetime)` is `False` for those (datetime is a subclass of date, so this check is reliable both ways). - `create_calendar_event` must never be called by Claude without the user first confirming in the conversation — enforced via system-prompt instruction (see spec's "Sicherheit" section), not a technical gate. - Tool-use intermediate turns (the `tool_use`/`tool_result` exchange) are never persisted to the `messages` table — only the final visible user/assistant pair, matching existing persistence behavior. --- ### Task 1: CalDAV helpers + config + unit tests **Files:** - Modify: `Claude outputs/main.py` (imports, config constants, `_caldav_calendar`, `list_upcoming_events`, `create_event`) - Modify: `Claude outputs/requirements.txt` (add `caldav`, `icalendar`) - Modify: `Claude outputs/docker-compose.yml` (jarvis-api environment) - Test: `Claude outputs/tests/test_calendar.py` **Interfaces:** - Produces: `async def list_upcoming_events(days_ahead: int) -> list` returning `[{"summary": str, "start": iso_str, "end": iso_str, "description": str, "all_day": bool}, ...]` sorted by start; `async def create_event(summary: str, start: str, end: str, description: str = "") -> dict` returning `{"summary", "start", "end", "description"}`. Consumed by Task 2 (endpoint) and Task 3 (chat tools). - [ ] **Step 1: Add dependencies** In `Claude outputs/requirements.txt`, add two lines: ``` caldav icalendar ``` (Unpinned, like `anthropic` — these are new enough that a pinned version chosen without checking PyPI risks picking one that doesn't exist; pip resolves the current compatible release at container build time.) - [ ] **Step 2: Write the failing tests** Create `Claude outputs/tests/test_calendar.py`: ```python import os import sys from datetime import datetime, date from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import main def _fake_component(summary, dtstart_value, dtend_value, description=""): comp = {} comp["summary"] = summary comp["description"] = description class _Val: def __init__(self, v): self.dt = v comp["dtstart"] = _Val(dtstart_value) comp["dtend"] = _Val(dtend_value) def get(key, default=None): return comp.get(key, default) fake = MagicMock() fake.get = get return fake def test_list_upcoming_events_maps_timed_and_all_day_events(): timed = MagicMock() timed.icalendar_component = _fake_component( "THL FM LS", datetime(2026, 9, 14, 19, 0, 0), datetime(2026, 9, 14, 20, 0, 0) ) all_day = MagicMock() all_day.icalendar_component = _fake_component( "Volksfestwache", date(2026, 9, 19), date(2026, 9, 20) ) fake_calendar = MagicMock() fake_calendar.search.return_value = [all_day, timed] # unsorted on purpose with patch.object(main, "_caldav_calendar", return_value=fake_calendar): events = main._list_upcoming_events_sync(14) assert [e["summary"] for e in events] == ["THL FM LS", "Volksfestwache"] assert events[0]["all_day"] is False assert events[1]["all_day"] is True def test_create_event_calls_save_event_with_parsed_dates(): fake_calendar = MagicMock() with patch.object(main, "_caldav_calendar", return_value=fake_calendar): result = main._create_event_sync( "JARVIS Testtermin", "2026-09-20T10:00:00", "2026-09-20T11:00:00", "desc" ) fake_calendar.save_event.assert_called_once() _, kwargs = fake_calendar.save_event.call_args assert kwargs["summary"] == "JARVIS Testtermin" assert kwargs["dtstart"] == datetime(2026, 9, 20, 10, 0, 0) assert result["summary"] == "JARVIS Testtermin" ``` - [ ] **Step 3: Run tests to verify they fail** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" ssh $SSHOPTS jarvis-core@72.61.186.98 "mkdir -p /tmp/jarvis-test/tests" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" "$OUT/requirements-dev.txt" jarvis-core@72.61.186.98:/tmp/jarvis-test/ scp $SSHOPTS "$OUT/tests/test_calendar.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/ ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_calendar.py -v'" ``` Expected: FAIL — `_caldav_calendar`/`_list_upcoming_events_sync`/`_create_event_sync` don't exist yet. - [ ] **Step 4: Add config constants** In `Claude outputs/main.py`, change the datetime import line: ```python from datetime import datetime ``` to: ```python from datetime import datetime, timedelta ``` Add near the top, after the other `import` lines (after `import aiohttp`): ```python import caldav ``` Add near the other `# ============ CONFIG ============` constants: ```python NEXTCLOUD_CALDAV_URL = os.getenv("NEXTCLOUD_CALDAV_URL") NEXTCLOUD_USER = os.getenv("NEXTCLOUD_USER") NEXTCLOUD_APP_PASSWORD = os.getenv("NEXTCLOUD_APP_PASSWORD") ``` - [ ] **Step 5: Add the CalDAV helpers** In `Claude outputs/main.py`, add after `get_latest_weather` (the last DB helper): ```python def _caldav_calendar(): dav_root = NEXTCLOUD_CALDAV_URL.split("/calendars/")[0] + "/" client = caldav.DAVClient(url=dav_root, username=NEXTCLOUD_USER, password=NEXTCLOUD_APP_PASSWORD) return client.calendar(url=NEXTCLOUD_CALDAV_URL) def _list_upcoming_events_sync(days_ahead: int) -> list: calendar = _caldav_calendar() start = datetime.now() end = start + timedelta(days=days_ahead) results = calendar.search(start=start, end=end, event=True, expand=True) events = [] for result in results: comp = result.icalendar_component dtstart = comp.get("dtstart").dt dtend_prop = comp.get("dtend") dtend = dtend_prop.dt if dtend_prop else dtstart events.append( { "summary": str(comp.get("summary", "")), "start": dtstart.isoformat(), "end": dtend.isoformat(), "description": str(comp.get("description", "")), "all_day": not isinstance(dtstart, datetime), } ) events.sort(key=lambda e: e["start"]) return events async def list_upcoming_events(days_ahead: int) -> list: return await asyncio.to_thread(_list_upcoming_events_sync, days_ahead) def _create_event_sync(summary: str, start: str, end: str, description: str = "") -> dict: calendar = _caldav_calendar() calendar.save_event( dtstart=datetime.fromisoformat(start), dtend=datetime.fromisoformat(end), summary=summary, description=description, ) return {"summary": summary, "start": start, "end": end, "description": description} async def create_event(summary: str, start: str, end: str, description: str = "") -> dict: return await asyncio.to_thread(_create_event_sync, summary, start, end, description) ``` - [ ] **Step 6: Run tests to verify they pass** Re-run the Step 3 command. Expected: 2 passed. - [ ] **Step 7: Wire the CalDAV env vars into the deployed container** In `Claude outputs/docker-compose.yml`, under `jarvis-api: environment:`, add after `API_KEY_ADMIN`: ```yaml - NEXTCLOUD_CALDAV_URL=https://cloud.ffw-onza.de/remote.php/dav/calendars/jonny/ffw-onza-alle/ - NEXTCLOUD_USER=jonny - NEXTCLOUD_APP_PASSWORD=${NEXTCLOUD_APP_PASSWORD:-} ``` Then add the actual password to the deployed `.env` (not `docker-compose.yml`, which stays committed-safe with the `${...}` reference): ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" ssh $SSHOPTS jarvis-core@72.61.186.98 "grep -q NEXTCLOUD_APP_PASSWORD /home/jarvis-core/jarvis/.env || echo 'NEXTCLOUD_APP_PASSWORD=j9Ywc-9Pnbk-8rQQw-Qnkb2-ZoqQJ' >> /home/jarvis-core/jarvis/.env" ``` - [ ] **Step 8: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 2: `GET /api/v1/calendar/events` endpoint **Files:** - Modify: `Claude outputs/main.py` (add the endpoint) **Interfaces:** - Consumes: `list_upcoming_events` (Task 1). - Produces: `GET /api/v1/calendar/events?days=14` returning `{"events": [...]}`, `503` on CalDAV failure. Consumed by Task 4's frontend widget. - [ ] **Step 1: Add the endpoint** In `Claude outputs/main.py`, add after the `get_weather` endpoint (end of file, before `if __name__ == "__main__":`): ```python @app.get("/api/v1/calendar/events", dependencies=[Depends(require_admin_key)]) async def get_calendar_events(days: int = 14): """Upcoming events from the FFW-Onza-Alle Nextcloud calendar""" try: events = await list_upcoming_events(days) return {"events": events} except Exception as e: logger.error(f"Calendar fetch error: {str(e)}") raise HTTPException(status_code=503, detail=f"Calendar unavailable: {str(e)}") ``` - [ ] **Step 2: Deploy and verify against the live API** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/main.py" "$OUT/requirements.txt" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/ scp $SSHOPTS "$OUT/docker-compose.yml" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/docker-compose.yml ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose up -d jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api" ``` Expected: log ends with `Application startup complete.` (no traceback — the container reinstalls `requirements.txt` on every start, so `caldav`/`icalendar` get pulled in automatically). ```bash ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-) ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -H 'X-Admin-Key: $ADMIN_KEY' 'http://localhost:8000/api/v1/calendar/events?days=30'; echo" ``` Expected: JSON with an `events` array containing real entries (e.g. "THL FM LS", "Volksfestwache" — the same events found during the connectivity check). - [ ] **Step 3: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 3: Chat tool use (query + confirmed create) **Files:** - Modify: `Claude outputs/main.py` (imports, tool definitions, `execute_tool`, `run_chat_completion`, rewire `chat()`) - Test: `Claude outputs/tests/test_chat_tools.py` **Interfaces:** - Consumes: `list_upcoming_events`, `create_event` (Task 1), `claude_client`, `CLAUDE_MODEL` (existing). - Produces: `async def run_chat_completion(claude_messages: list) -> tuple[str, int, int]` returning `(response_text, output_tokens, total_tokens)`. Replaces the inline `claude_client.messages.create` call in `chat()`. - [ ] **Step 1: Write the failing test** Create `Claude outputs/tests/test_chat_tools.py`: ```python import os import sys from unittest.mock import AsyncMock, MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import pytest import main def _text_block(text): block = MagicMock() block.type = "text" block.text = text return block def _tool_use_block(name, tool_input, tool_id="tool_1"): block = MagicMock() block.type = "tool_use" block.name = name block.input = tool_input block.id = tool_id return block def _usage(input_tokens, output_tokens): usage = MagicMock() usage.input_tokens = input_tokens usage.output_tokens = output_tokens return usage @pytest.mark.asyncio async def test_run_chat_completion_without_tool_use(): completion = MagicMock() completion.stop_reason = "end_turn" completion.content = [_text_block("Hallo!")] completion.usage = _usage(10, 5) main.claude_client = MagicMock() main.claude_client.messages.create.return_value = completion text, output_tokens, total_tokens = await main.run_chat_completion([{"role": "user", "content": "Hi"}]) assert text == "Hallo!" assert output_tokens == 5 assert total_tokens == 15 @pytest.mark.asyncio async def test_run_chat_completion_executes_tool_and_returns_followup(): first = MagicMock() first.stop_reason = "tool_use" first.content = [_tool_use_block("list_calendar_events", {"days_ahead": 7})] first.usage = _usage(20, 8) second = MagicMock() second.stop_reason = "end_turn" second.content = [_text_block("Naechste Woche steht nichts an.")] second.usage = _usage(30, 12) main.claude_client = MagicMock() main.claude_client.messages.create.side_effect = [first, second] with patch.object(main, "list_upcoming_events", new=AsyncMock(return_value=[])): text, output_tokens, total_tokens = await main.run_chat_completion( [{"role": "user", "content": "Was steht diese Woche an?"}] ) assert text == "Naechste Woche steht nichts an." assert output_tokens == 8 + 12 assert total_tokens == 20 + 8 + 30 + 12 assert main.claude_client.messages.create.call_count == 2 ``` Note: this test file needs `pytest-asyncio` to run `async def test_...` functions — add it to `Claude outputs/requirements-dev.txt` in this step (append `pytest-asyncio==0.24.0`), and create `Claude outputs/pytest.ini` with: ```ini [pytest] asyncio_mode = auto ``` - [ ] **Step 2: Run tests to verify they fail** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/requirements-dev.txt" "$OUT/pytest.ini" jarvis-core@72.61.186.98:/tmp/jarvis-test/ scp $SSHOPTS "$OUT/tests/test_chat_tools.py" jarvis-core@72.61.186.98:/tmp/jarvis-test/tests/ ssh $SSHOPTS jarvis-core@72.61.186.98 "docker run --rm -v /tmp/jarvis-test:/app -w /app python:3.11-slim bash -c 'pip install -q -r requirements-dev.txt -r requirements.txt && python -m pytest tests/test_chat_tools.py -v'" ``` Expected: FAIL — `run_chat_completion` doesn't exist yet. - [ ] **Step 3: Add `import json`** In `Claude outputs/main.py`, add `import json` next to `import logging`. - [ ] **Step 4: Add the tool definitions and execution dispatcher** Add after the CalDAV helpers from Task 1 (after `create_event`): ```python CALENDAR_TOOLS = [ { "name": "list_calendar_events", "description": "List upcoming events from the FFW-Onza-Alle calendar within the next N days.", "input_schema": { "type": "object", "properties": { "days_ahead": { "type": "integer", "description": "How many days ahead to look, e.g. 7 for the next week", } }, "required": ["days_ahead"], }, }, { "name": "create_calendar_event", "description": ( "Create a new event in the FFW-Onza-Alle calendar. Only call this " "after the user has explicitly confirmed the event details " "(title, date, time) in the conversation." ), "input_schema": { "type": "object", "properties": { "summary": {"type": "string", "description": "Event title"}, "start": {"type": "string", "description": "Start date/time in ISO 8601, e.g. 2026-09-20T10:00:00"}, "end": {"type": "string", "description": "End date/time in ISO 8601, e.g. 2026-09-20T11:00:00"}, "description": {"type": "string", "description": "Optional longer description"}, }, "required": ["summary", "start", "end"], }, }, ] CALENDAR_ASSISTANT_INSTRUCTIONS = ( "Du hast Zugriff auf den Kalender 'FFW-Onza-Alle' ueber die Tools " "list_calendar_events und create_calendar_event. Bevor du " "create_calendar_event aufrufst, frage den Nutzer immer explizit im " "Klartext nach Bestaetigung der Termindetails (Titel, Datum, Uhrzeit) " "und rufe das Tool erst auf, nachdem der Nutzer im naechsten " "Chat-Beitrag zugestimmt hat." ) async def execute_tool(name: str, tool_input: dict) -> str: if name == "list_calendar_events": events = await list_upcoming_events(tool_input.get("days_ahead", 14)) return json.dumps(events) if name == "create_calendar_event": result = await create_event( tool_input["summary"], tool_input["start"], tool_input["end"], tool_input.get("description", ""), ) return json.dumps(result) raise ValueError(f"Unknown tool: {name}") ``` - [ ] **Step 5: Add `run_chat_completion`** Add directly after `execute_tool`: ```python async def run_chat_completion(claude_messages: list): system_prompt = f"{CLAUDE_SYSTEM_PROMPT}\n\n{CALENDAR_ASSISTANT_INSTRUCTIONS}" completion = await asyncio.to_thread( claude_client.messages.create, model=CLAUDE_MODEL, max_tokens=1024, system=system_prompt, tools=CALENDAR_TOOLS, messages=claude_messages, ) total_input = completion.usage.input_tokens total_output = completion.usage.output_tokens if completion.stop_reason != "tool_use": response_text = "".join(b.text for b in completion.content if b.type == "text") return response_text, total_output, total_input + total_output tool_results = [] for block in completion.content: if block.type != "tool_use": continue try: result_text = await execute_tool(block.name, block.input) tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result_text}) except Exception as e: tool_results.append( {"type": "tool_result", "tool_use_id": block.id, "content": str(e), "is_error": True} ) followup_messages = claude_messages + [ {"role": "assistant", "content": completion.content}, {"role": "user", "content": tool_results}, ] followup = await asyncio.to_thread( claude_client.messages.create, model=CLAUDE_MODEL, max_tokens=1024, system=system_prompt, tools=CALENDAR_TOOLS, messages=followup_messages, ) total_input += followup.usage.input_tokens total_output += followup.usage.output_tokens response_text = "".join(b.text for b in followup.content if b.type == "text") return response_text, total_output, total_input + total_output ``` - [ ] **Step 6: Run tests to verify they pass** Re-run the Step 2 command. Expected: 2 passed. - [ ] **Step 7: Rewire `chat()` to use it** In `Claude outputs/main.py`, inside `chat()`, replace: ```python completion = await asyncio.to_thread( claude_client.messages.create, model=CLAUDE_MODEL, max_tokens=1024, system=CLAUDE_SYSTEM_PROMPT, messages=claude_messages, ) response_text = "".join(block.text for block in completion.content if block.type == "text") input_tokens = completion.usage.input_tokens output_tokens = completion.usage.output_tokens tokens_used = input_tokens + output_tokens await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens) ``` with: ```python response_text, output_tokens, tokens_used = await run_chat_completion(claude_messages) await save_message(conversation_id, DEFAULT_USER_ID, "assistant", response_text, output_tokens) ``` - [ ] **Step 8: Deploy and verify against the live API** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS\Claude outputs" scp $SSHOPTS "$OUT/main.py" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/api/main.py ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose restart jarvis-api && sleep 10 && docker logs --tail 20 jarvis-api" ``` Expected: log ends with `Application startup complete.` (no traceback). Then: ```bash ADMIN_KEY=$(ssh $SSHOPTS jarvis-core@72.61.186.98 "grep API_KEY_ADMIN /home/jarvis-core/jarvis/.env" | cut -d= -f2-) ssh $SSHOPTS jarvis-core@72.61.186.98 "curl -s -X POST http://localhost:8000/api/v1/chat -H 'Content-Type: application/json' -H 'X-Admin-Key: $ADMIN_KEY' -d '{\"message\": \"Was steht in den naechsten 30 Tagen im Kalender an?\"}'; echo" ``` Expected: a natural-language response mentioning real events from the calendar (e.g. "THL FM LS", "Volksfestwache") — proof the tool round-trip actually executed against the live Nextcloud calendar, not a hallucinated answer. - [ ] **Step 9: Confirm files saved** (no git repo for this project — see Global Constraints) --- ### Task 4: "Nächste Termine" dashboard widget **Files:** - Create: `web/src/components/CalendarWidget.tsx` - Modify: `web/src/components/Dashboard.tsx` (render it) **Interfaces:** - Consumes: `apiFetch` (existing), `GET /api/v1/calendar/events` (Task 2). - Produces: `CalendarWidget` default export, no props, rendered inside `Dashboard`. - [ ] **Step 1: Create the widget** Create `web/src/components/CalendarWidget.tsx`: ```tsx import { useEffect, useState } from "react"; import { apiFetch } from "../api"; type CalendarEvent = { summary: string; start: string; all_day: boolean }; export default function CalendarWidget() { const [events, setEvents] = useState(null); const [error, setError] = useState(null); useEffect(() => { async function load() { try { const response = await apiFetch("/api/v1/calendar/events?days=14"); if (!response.ok) { setError("Kalender nicht verfuegbar"); return; } const data = await response.json(); setEvents(data.events); } catch { setError("Kalender nicht verfuegbar"); } } load(); }, []); function formatEvent(e: CalendarEvent): string { const date = new Date(e.start); const dateStr = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }); if (e.all_day) return `${dateStr}: ${e.summary}`; const timeStr = date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); return `${dateStr} ${timeStr}: ${e.summary}`; } return (

N\u00e4chste Termine

{error &&

{error}

} {events && events.length === 0 &&

Keine Termine in den n\u00e4chsten 14 Tagen.

} {events && events.length > 0 && (
    {events.map((e, i) => (
  • {formatEvent(e)}
  • ))}
)}
); } ``` - [ ] **Step 2: Render it in the Dashboard** In `web/src/components/Dashboard.tsx`, add the import next to the other imports: ```tsx import CalendarWidget from "./CalendarWidget"; ``` Add `` right after the closing `` of the health table, still inside the outer `
`. - [ ] **Step 3: Verify the build works** ```bash cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web" npm run build ``` Expected: exits 0. - [ ] **Step 4: Deploy** ```bash SSHOPTS="-F /dev/null -o IdentitiesOnly=yes -i /c/Users/Jonny/.ssh/jarvis_core_key" OUT="C:\Users\Jonny\Projekte\Claude\JARVIS" scp $SSHOPTS -r "$OUT\web\src" jarvis-core@72.61.186.98:/home/jarvis-core/jarvis/web/ ssh $SSHOPTS jarvis-core@72.61.186.98 "cd /home/jarvis-core/jarvis && docker compose build jarvis-web && docker compose up -d jarvis-web" ``` - [ ] **Step 5: End-to-end browser verification** Using the claude-in-chrome browser tools: log in at `https://jarvis.mbo-tech-it.de`, open Dashboard, confirm "Nächste Termine" lists real events (e.g. "THL FM LS"). Then in Chat, ask "Was steht diese Woche im Kalender an?" and confirm a real-data answer. Then say something like "Leg einen Termin 'JARVIS Verbindungstest' am 25.09.2026 von 14 bis 15 Uhr an" and confirm JARVIS asks for confirmation before creating anything; confirm it, then verify the event actually appears (re-query the widget or the calendar endpoint) — afterwards delete that test event directly against the calendar (reuse the connectivity-check pattern: a one-off `caldav` script via `docker run python:3.11-slim`, searching by summary and calling `.delete()`) so the real organisation calendar stays clean. - [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)