chore: feat/wetter → main (Version 1.15.0)
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
+84
-8
@@ -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<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();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<HTMLFormElement>(null);
|
||||
const prevArtRef = useRef<ArtFuehrung | null>(null);
|
||||
const prevEndeRef = useRef<string | null>(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<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(() => {
|
||||
if (editEntry) {
|
||||
setArtFuehrung(editEntry.ArtFuehrung);
|
||||
@@ -419,6 +460,9 @@ export default function LogbuchForm({ kuppel, currentUserBeo, editEntry, onSaved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{wetterHinweis && (
|
||||
<div className="mt-1 text-xs text-amber-700">{wetterHinweis}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "logbuch",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user