diff --git a/ANLEITUNG.md b/ANLEITUNG.md index 62c3afc..a534260 100644 --- a/ANLEITUNG.md +++ b/ANLEITUNG.md @@ -103,7 +103,11 @@ Unterhalb der Kuppelauswahl gibt es vier Tabs: **Bemerkungen** – freier Text, max. 500 Zeichen. -**Wetterdaten** – Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch vom lokalen Wetterdienst vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. `-5`) können direkt eingegeben werden. +**Wetterdaten** – Temperatur (°C), Luftfeuchtigkeit (%) und Luftdruck (hPa) werden automatisch von der Wetterstation vorausgefüllt und können manuell korrigiert werden. Negative Temperaturen (z. B. `-5`) können direkt eingegeben werden. + +Beim Öffnen des Formulars erscheint der aktuelle Messwert. Wird anschließend **Datum oder Endzeit geändert**, werden die drei Werte automatisch auf den Messwert zu diesem Zeitpunkt umgestellt — ein nachträglich erfasster Eintrag bekommt so das Wetter, das damals herrschte, und nicht das von heute. Maßgeblich ist die **Endzeit** des Eintrags; verwendet wird die Messung, die dieser Uhrzeit am nächsten liegt. + +> [!HINWEIS] Gibt es zu dem Zeitpunkt keine Messung — etwa bei einem Termin in der Zukunft oder während eines Ausfalls der Station — bleiben die bisherigen Werte stehen und es erscheint ein kurzer Hinweis unter den Feldern. Die Werte können dann von Hand eingetragen werden. ### Eintrag speichern diff --git a/CLAUDE.md b/CLAUDE.md index 002e122..2fc0c42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,10 @@ Saving an `SF` entry also writes back into the Sonderführungs register `SoFue2` Führungen that fall out never produce a logbuch entry. The **Führungen** tab (`components/Fuehrungen.tsx`, open to every logged-in BEO) lists upcoming `status=2` rows and offers cancel (`status=3`, date kept) and postpone (old `wtermin` saved into the long-unused `verlegt` column, new date written). Both append a dated line with the user's Kürzel to `bemerkung` rather than replacing it. Backed by `LB_SOFUE_TERMINE` / `LB_SOFUE_ABSAGEN` / `LB_SOFUE_VERSCHIEBEN` in `DB4js_all.php` and the routes under `app/api/sofue/`. +## Weather + +`app/api/wetter/route.ts` proxies `stwwetter.fuerst-stuttgart.de/api/weather`. Without params it returns `/latest`; with `?zeit=YYYY-MM-DDTHH:MM` (local time) it queries `/range` ±20 min around that moment and returns the closest reading, or 404 when the station has nothing (future dates, outages). **The weather API treats naive query params as UTC**, so the route converts Europe/Berlin → UTC via `Intl` offsets — without that, summer readings are off by two hours. `LogbuchForm` refetches whenever `ende` changes (date or Endzeit), skipping the first run so an edited entry keeps its stored values. + ## Documentation `ANLEITUNG.md` is the single source for the user manual — edit only this file. `public/anleitung.html` (linked from the app footer) is **generated** by `scripts/build-anleitung.mjs` and is untracked (`.gitignore`); never hand-edit it. `npm run anleitung` builds it; a `prebuild` hook runs the same script before every `next build`, so the Docker image always gets a current copy (`.dockerignore` excludes `*.md` but re-includes `ANLEITUNG.md` for exactly this). diff --git a/app/api/wetter/route.ts b/app/api/wetter/route.ts index e454279..597085b 100644 --- a/app/api/wetter/route.ts +++ b/app/api/wetter/route.ts @@ -1,16 +1,92 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getSession } from '@/lib/session'; -export async function GET() { +const BASIS = 'https://stwwetter.fuerst-stuttgart.de/api/weather'; +const ZONE = 'Europe/Berlin'; +// Suchfenster um den gewünschten Zeitpunkt; die Station misst etwa alle 30 Sekunden. +const FENSTER_MIN = 20; + +interface Messung { + datetime: string; + temperature: number; + humidity: number; + pressure: number; +} + +/** Offset der Zone zum angegebenen Zeitpunkt in Minuten (im Sommer +120). */ +function zonenOffset(zeitpunkt: Date): number { + const teile = new Intl.DateTimeFormat('en-US', { + timeZone: ZONE, hour12: false, + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', + }).formatToParts(zeitpunkt); + const w = Object.fromEntries(teile.map((t) => [t.type, t.value])) as Record; + const alsUtc = Date.UTC(+w.year, +w.month - 1, +w.day, +w.hour % 24, +w.minute, +w.second); + return (alsUtc - zeitpunkt.getTime()) / 60000; +} + +/** + * Lokale Zeit ("YYYY-MM-DDTHH:MM", Europe/Berlin) in echte UTC umrechnen. + * Die Wetter-API deutet Parameter ohne Zeitzone als UTC — ohne Umrechnung läge + * das Ergebnis im Sommer zwei Stunden daneben. + */ +function lokalNachUtc(lokal: string): Date { + const naiv = Date.parse(lokal + ':00Z'); + // Zwei Durchgänge, damit auch die Zeitumstellungs-Nacht richtig landet. + let utc = naiv - zonenOffset(new Date(naiv)) * 60000; + utc = naiv - zonenOffset(new Date(utc)) * 60000; + return new Date(utc); +} + +/** Ohne Millisekunden und ohne 'Z' — so erwartet die Wetter-API ihre Parameter. */ +function alsParameter(d: Date): string { + return d.toISOString().slice(0, 19); +} + +function aufbereiten(m: Messung) { + return { + temp: Math.round(m.temperature * 10) / 10, + feuchte: Math.round(m.humidity), + druck: Math.round(m.pressure), + }; +} + +export async function GET(request: NextRequest) { const session = await getSession(); if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); - const res = await fetch('https://stwwetter.fuerst-stuttgart.de/api/weather/latest', { cache: 'no-store' }); + const zeit = new URL(request.url).searchParams.get('zeit'); + + // Ohne Zeitangabe wie bisher: der aktuelle Messwert. + if (!zeit) { + const res = await fetch(`${BASIS}/latest`, { cache: 'no-store' }); + if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 }); + return NextResponse.json(aufbereiten(await res.json())); + } + + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(zeit)) { + return NextResponse.json({ error: 'Parameter zeit ungültig (YYYY-MM-DDTHH:MM)' }, { status: 400 }); + } + + const ziel = lokalNachUtc(zeit); + const von = new Date(ziel.getTime() - FENSTER_MIN * 60000); + const bis = new Date(ziel.getTime() + FENSTER_MIN * 60000); + + const res = await fetch( + `${BASIS}/range?start=${alsParameter(von)}&end=${alsParameter(bis)}&limit=200`, + { cache: 'no-store' } + ); if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 }); - const data = await res.json(); - const temp = Math.round(data.temperature * 10) / 10; - const feuchte = Math.round(data.humidity); - const druck = Math.round(data.pressure); - return NextResponse.json({ temp, feuchte, druck }); + const messungen: Messung[] = await res.json(); + if (!Array.isArray(messungen) || messungen.length === 0) { + // Kommt bei künftigen Zeitpunkten und bei Ausfällen der Station vor. + return NextResponse.json({ error: 'Keine Wetterdaten zu diesem Zeitpunkt' }, { status: 404 }); + } + + const naechste = messungen.reduce((a, b) => + Math.abs(Date.parse(a.datetime) - ziel.getTime()) <= Math.abs(Date.parse(b.datetime) - ziel.getTime()) ? a : b + ); + + return NextResponse.json(aufbereiten(naechste)); } diff --git a/components/LogbuchForm.tsx b/components/LogbuchForm.tsx index a6d47ad..a15efb6 100644 --- a/components/LogbuchForm.tsx +++ b/components/LogbuchForm.tsx @@ -54,11 +54,13 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [success, setSuccess] = useState(false); + const [wetterHinweis, setWetterHinweis] = useState(''); const editEntryRef = useRef(editEntry); editEntryRef.current = editEntry; const formRef = useRef(null); const prevArtRef = useRef(null); + const prevEndeRef = useRef(null); function focusNext(current: HTMLElement) { if (!formRef.current) return; @@ -87,6 +89,45 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved .catch(() => {}); }, []); + // Ändert der Benutzer Datum oder Endzeit, wird das Wetter zu diesem Zeitpunkt + // nachgeladen. Der erste Lauf wird übersprungen: beim Anlegen liefert bereits der + // Effekt oben den aktuellen Wert, beim Bearbeiten stehen die gespeicherten Werte drin. + useEffect(() => { + if (prevEndeRef.current === null) { + prevEndeRef.current = ende; + return; + } + if (prevEndeRef.current === ende) return; + prevEndeRef.current = ende; + + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(ende)) return; + + let verworfen = false; + fetch(`/api/wetter?zeit=${encodeURIComponent(ende)}`) + .then(async (r) => { + if (r.status === 404) throw new Error('keine'); + if (!r.ok) throw new Error('fehler'); + return r.json() as Promise; + }) + .then((w) => { + if (verworfen) return; + setWetter({ temp: w.temp, feuchte: Math.round(w.feuchte), druck: Math.round(w.druck) }); + setTempRaw(String(w.temp)); + setWetterHinweis(''); + }) + .catch((e: Error) => { + // Bestehende Werte stehen lassen — ein leeres Formular wäre schlechter als alte Zahlen. + if (!verworfen) { + setWetterHinweis( + e.message === 'keine' + ? 'Keine Wetterdaten zu diesem Zeitpunkt — Werte bitte selbst eintragen.' + : 'Wetterdaten konnten nicht geladen werden.' + ); + } + }); + return () => { verworfen = true; }; + }, [ende]); + useEffect(() => { if (editEntry) { setArtFuehrung(editEntry.ArtFuehrung); @@ -419,6 +460,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved /> + {wetterHinweis && ( +
{wetterHinweis}
+ )} {error && ( diff --git a/package.json b/package.json index d517800..a1e0aaf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logbuch", - "version": "1.14.0", + "version": "1.15.0", "private": true, "scripts": { "dev": "next dev",