jarvis-assist/docs/superpowers/plans/2026-09-13-calendar-integra...

674 lines
26 KiB
Markdown

# 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<CalendarEvent[] | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="calendar-widget">
<h3>N\u00e4chste Termine</h3>
{error && <p className="error">{error}</p>}
{events && events.length === 0 && <p>Keine Termine in den n\u00e4chsten 14 Tagen.</p>}
{events && events.length > 0 && (
<ul>
{events.map((e, i) => (
<li key={i}>{formatEvent(e)}</li>
))}
</ul>
)}
</div>
);
}
```
- [ ] **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 `<CalendarWidget />` right after the closing `</table>` of the health table, still inside the outer `<div className="dashboard">`.
- [ ] **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)