chore: feat/wetter → main (Version 1.15.0)

This commit is contained in:
2026-08-08 21:11:17 +02:00
5 changed files with 138 additions and 10 deletions
+5 -1
View File
@@ -103,7 +103,11 @@ Unterhalb der Kuppelauswahl gibt es vier Tabs:
**Bemerkungen** freier Text, max. 500 Zeichen. **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 ### Eintrag speichern
+4
View File
@@ -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/`. 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 ## 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). `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).
+84 -8
View File
@@ -1,16 +1,92 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session'; 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<string, string>;
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(); const session = await getSession();
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); 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 }); if (!res.ok) return NextResponse.json({ error: 'Wetterdaten nicht verfügbar' }, { status: 502 });
const data = await res.json(); const messungen: Messung[] = await res.json();
const temp = Math.round(data.temperature * 10) / 10; if (!Array.isArray(messungen) || messungen.length === 0) {
const feuchte = Math.round(data.humidity); // Kommt bei künftigen Zeitpunkten und bei Ausfällen der Station vor.
const druck = Math.round(data.pressure); return NextResponse.json({ error: 'Keine Wetterdaten zu diesem Zeitpunkt' }, { status: 404 });
return NextResponse.json({ temp, feuchte, druck }); }
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));
} }
+44
View File
@@ -54,11 +54,13 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [wetterHinweis, setWetterHinweis] = useState('');
const editEntryRef = useRef(editEntry); const editEntryRef = useRef(editEntry);
editEntryRef.current = editEntry; editEntryRef.current = editEntry;
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const prevArtRef = useRef<ArtFuehrung | null>(null); const prevArtRef = useRef<ArtFuehrung | null>(null);
const prevEndeRef = useRef<string | null>(null);
function focusNext(current: HTMLElement) { function focusNext(current: HTMLElement) {
if (!formRef.current) return; if (!formRef.current) return;
@@ -87,6 +89,45 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
.catch(() => {}); .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<Wetter>;
})
.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(() => { useEffect(() => {
if (editEntry) { if (editEntry) {
setArtFuehrung(editEntry.ArtFuehrung); setArtFuehrung(editEntry.ArtFuehrung);
@@ -419,6 +460,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
/> />
</div> </div>
</div> </div>
{wetterHinweis && (
<div className="mt-1 text-xs text-amber-700">{wetterHinweis}</div>
)}
</div> </div>
{error && ( {error && (
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "logbuch", "name": "logbuch",
"version": "1.14.0", "version": "1.15.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",