feat: Wetter zum gewählten Datum und zur Endzeit übernehmen — Version 1.15.0
Bisher stand im Formular immer der aktuelle Messwert, auch bei nachträglich erfassten Einträgen. Ändert der Benutzer nun Datum oder Endzeit, werden Temperatur, Feuchte und Luftdruck auf die Messung zu diesem Zeitpunkt umgestellt. /api/wetter nimmt dafür optional ?zeit=YYYY-MM-DDTHH:MM, holt über /range das Fenster ±20 Minuten und liefert die nächstgelegene Messung. Wichtig: die Wetter-API deutet Parameter ohne Zeitzone als UTC — die Route rechnet deshalb Europe/Berlin um, sonst läge das Ergebnis im Sommer zwei Stunden daneben. Gibt es keine Messung (Zukunft, Ausfall der Station), bleiben die bisherigen Werte stehen und es erscheint ein Hinweis unter den Feldern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user