jarvis-assist/docs/superpowers/plans/2026-09-12-weather-widget.md

294 lines
10 KiB
Markdown

# JARVIS Weather Widget 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:** Expose the weather data the new n8n workflow writes to Postgres via a protected API endpoint, and show it top-right in the frontend nav bar.
**Architecture:** `GET /api/v1/weather` reads the newest row from `weather_cache` (already populated by the "Wetter Crailsheim" n8n workflow, verified working). A new `WeatherWidget` React component polls that endpoint and renders in the nav bar.
**Tech Stack:** FastAPI (existing `db_query`/`require_admin_key` patterns), React (existing `apiFetch` pattern).
**Spec:** `docs/superpowers/specs/2026-09-12-weather-widget-design.md`
## Global Constraints
- No dedicated git repository for this project (see prior plans) — 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`. Local backend files: `Claude outputs/` (quote the path). Local frontend files: `web/`.
- The n8n workflow "Wetter Crailsheim" (id `BwNCJ2TkuZfqUzst`), its Postgres credential "JARVIS Postgres", and the `weather_cache` table already exist and are verified working (one row present from a manual test run) — this plan does not touch them.
- `weather_cache` schema: `id, location, temperature_c NUMERIC, condition_code INTEGER, condition_text VARCHAR(100), fetched_at TIMESTAMP`.
- The endpoint must use `dependencies=[Depends(require_admin_key)]` like every other `/api/v1/*` route (see `Claude outputs/main.py`).
---
### Task 1: `GET /api/v1/weather` endpoint
**Files:**
- Modify: `Claude outputs/main.py` (add `get_latest_weather` DB helper + `GET /api/v1/weather` endpoint)
- Test: `Claude outputs/tests/test_weather.py`
**Interfaces:**
- Produces: `GET /api/v1/weather` returning `{"location": str, "temperature_c": float, "condition_text": str, "fetched_at": str}` on success, `503` when `weather_cache` is empty. Consumed by Task 2's `WeatherWidget`.
- [ ] **Step 1: Write the failing test**
Create `Claude outputs/tests/test_weather.py`:
```python
import os
import sys
from datetime import datetime
from unittest.mock import AsyncMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from fastapi.testclient import TestClient
import main
main.API_KEY_ADMIN = "test-secret"
HEADERS = {"X-Admin-Key": "test-secret"}
@pytest.fixture()
def client():
with TestClient(main.app) as c:
yield c
def test_weather_returns_503_when_no_data(client):
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=None)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 503
def test_weather_returns_latest_row(client):
row = {
"location": "Crailsheim",
"temperature_c": 18.4,
"condition_text": "Bewoelkt",
"fetched_at": datetime(2026, 9, 12, 16, 0, 3, 123456),
}
with patch.object(main, "pg_pool", "not-none"), patch.object(
main, "get_latest_weather", new=AsyncMock(return_value=row)
):
response = client.get("/api/v1/weather", headers=HEADERS)
assert response.status_code == 200
assert response.json()["location"] == "Crailsheim"
def test_weather_requires_admin_key(client):
response = client.get("/api/v1/weather")
assert response.status_code == 401
```
Note: `patch.object(main, "pg_pool", "not-none")` stands in for "database configured" — the endpoint only checks `if not pg_pool`, so any truthy value works without needing a real connection pool.
- [ ] **Step 2: Run test to verify it fails**
```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_weather.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_weather.py -v'"
```
Expected: FAIL — `get_latest_weather` doesn't exist / `/api/v1/weather` is 404.
- [ ] **Step 3: Add the DB helper and endpoint**
In `Claude outputs/main.py`, add near the other DB helpers (after `search_chunks`):
```python
async def get_latest_weather():
return await db_query(
"""
SELECT location, temperature_c, condition_text, fetched_at
FROM weather_cache ORDER BY fetched_at DESC LIMIT 1
""",
fetch="one",
)
```
Add the endpoint near the other admin/status endpoints (after `detailed_health`):
```python
@app.get("/api/v1/weather", dependencies=[Depends(require_admin_key)])
async def get_weather():
"""Latest cached weather reading, populated by the n8n 'Wetter Crailsheim' workflow"""
if not pg_pool:
raise HTTPException(status_code=503, detail="Database is not configured")
row = await get_latest_weather()
if not row:
raise HTTPException(status_code=503, detail="Weather data not available yet")
return {
"location": row["location"],
"temperature_c": float(row["temperature_c"]),
"condition_text": row["condition_text"],
"fetched_at": row["fetched_at"].isoformat(),
}
```
- [ ] **Step 4: Run test to verify it passes**
Re-run the Step 2 command. Expected: 3 passed.
- [ ] **Step 5: 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 8 && docker logs --tail 15 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 -H 'X-Admin-Key: $ADMIN_KEY' http://localhost:8000/api/v1/weather; echo"
```
Expected: JSON with `"location":"Crailsheim"` and the temperature/condition from the test run done during n8n workflow setup.
- [ ] **Step 6: Confirm files saved** (no git repo for this project — see Global Constraints)
---
### Task 2: `WeatherWidget` in the nav bar
**Files:**
- Create: `web/src/components/WeatherWidget.tsx`
- Modify: `web/src/App.tsx` (render the widget in the nav)
- Modify: `web/src/index.css` (right-align the widget in the nav)
**Interfaces:**
- Consumes: `apiFetch` from `./api` (existing), `GET /api/v1/weather` (Task 1).
- Produces: `WeatherWidget` default export, no props, rendered inside the existing `<nav className="nav">` in `App.tsx`.
- [ ] **Step 1: Add the nav CSS for right-alignment**
In `web/src/index.css`, add after the `.nav button` rule:
```css
.weather-widget { margin-left: auto; font-size: 0.9rem; color: #94a3b8; align-self: center; }
```
- [ ] **Step 2: Create the component**
Create `web/src/components/WeatherWidget.tsx`:
```tsx
import { useEffect, useState } from "react";
import { apiFetch } from "../api";
type Weather = { location: string; temperature_c: number; condition_text: string };
const REFRESH_MS = 5 * 60 * 1000;
export default function WeatherWidget() {
const [weather, setWeather] = useState<Weather | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const response = await apiFetch("/api/v1/weather");
if (!response.ok) {
if (!cancelled) setFailed(true);
return;
}
const data = await response.json();
if (!cancelled) {
setWeather(data);
setFailed(false);
}
} catch {
if (!cancelled) setFailed(true);
}
}
load();
const interval = setInterval(load, REFRESH_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
if (failed) {
return <span className="weather-widget">Wetter nicht verfuegbar</span>;
}
if (!weather) {
return null;
}
return (
<span className="weather-widget">
{`${weather.location}: ${weather.temperature_c.toFixed(1)}\u00b0C \u00b7 ${weather.condition_text}`}
</span>
);
}
```
- [ ] **Step 3: Render it in the nav bar**
In `web/src/App.tsx`, add the import next to the other component imports:
```tsx
import WeatherWidget from "./components/WeatherWidget";
```
Change the `<nav>` block from:
```tsx
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => logout()}>Logout</button>
</nav>
```
to:
```tsx
<nav className="nav">
<button onClick={() => setView("chat")} disabled={view === "chat"}>Chat</button>
<button onClick={() => setView("dashboard")} disabled={view === "dashboard"}>Dashboard</button>
<button onClick={() => logout()}>Logout</button>
<WeatherWidget />
</nav>
```
- [ ] **Step 4: Verify the build works**
```bash
cd "C:\Users\Jonny\Projekte\Claude\JARVIS\web"
npm run build
```
Expected: exits 0.
- [ ] **Step 5: 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 6: End-to-end browser verification**
Using the claude-in-chrome browser tools: open `https://jarvis.mbo-tech-it.de`, log in with the real `API_KEY_ADMIN`, confirm the weather text (e.g. "Crailsheim: 20.9°C · Bewoelkt") appears top-right of the nav bar on both the Chat and Dashboard views.
- [ ] **Step 7: Confirm files saved** (no git repo for this project — see Global Constraints)