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

26 KiB

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 datetimeisinstance(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:

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
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:

from datetime import datetime

to:

from datetime import datetime, timedelta

Add near the top, after the other import lines (after import aiohttp):

import caldav

Add near the other # ============ CONFIG ============ constants:

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):

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:

      - 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):

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__":):

@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
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).

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:

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:

[pytest]
asyncio_mode = auto
  • Step 2: Run tests to verify they fail
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):

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:

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:

        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:

        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
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:

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:

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:

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
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build

Expected: exits 0.

  • Step 4: Deploy
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)