294 lines
10 KiB
Markdown
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)
|