From b85b741116663acef0badf8c2d5dc54658fbd34c Mon Sep 17 00:00:00 2001 From: "Patrick Seidler (via Claude Code)" Date: Wed, 5 Aug 2026 21:29:35 +0000 Subject: [PATCH] fix(ris): Provox-Sitzungstermine in echter Berliner Ortszeit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provox.ts verdrahtete den UTC-Offset fest auf +01:00. Im gesamten Sommerhalbjahr — also auch jetzt — lag damit jeder importierte Sitzungstermin eine Stunde zu frueh: eine Sitzung um 19:30 erschien als 18:30. Die Rechnung geht jetzt ueber Intl statt ueber eine feste Zahl; die Regeln der Zeitumstellung stehen damit in der ICU-Datenbank der Laufzeit und nicht in unserem Code. Zwei Durchlaeufe, damit auch die Umstellungsnaechte stimmen. Bewusst als eigenes Modul lib/ris/zeitzone.ts und NICHT aus allris.ts herausgezogen: Der ALLRIS-Parser steht bis nach dem Pitch unter Fix-Freeze. Die Dopplung ist dokumentiert; der geplante ALLRIS-Rewrite schwenkt um. 7 neue Tests, darunter beide Umstellungstage 2026 und eine Rueckprobe ueber alle zwoelf Monate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018YNj1kkesDywuEGvNweBHM --- app/src/lib/ris/__tests__/zeitzone.test.ts | 58 +++++++++++++++++++ app/src/lib/ris/provox.ts | 10 +++- app/src/lib/ris/zeitzone.ts | 67 ++++++++++++++++++++++ 3 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 app/src/lib/ris/__tests__/zeitzone.test.ts create mode 100644 app/src/lib/ris/zeitzone.ts diff --git a/app/src/lib/ris/__tests__/zeitzone.test.ts b/app/src/lib/ris/__tests__/zeitzone.test.ts new file mode 100644 index 0000000..5571970 --- /dev/null +++ b/app/src/lib/ris/__tests__/zeitzone.test.ts @@ -0,0 +1,58 @@ +/** + * Prüft die Umrechnung Berliner Ortszeit → Zeitpunkt, insbesondere über die + * Sommerzeitgrenze hinweg. Der Bug, den das verhindert: ein fest verdrahtetes + * "+01:00" ließ jede Sitzung im Sommerhalbjahr eine Stunde zu früh erscheinen. + */ + +import { describe, it, expect } from "vitest"; +import { berlinZeitZuDate } from "../zeitzone"; + +/** Kurzschreibweise: erwarteter UTC-Zeitpunkt als ISO-String. */ +const utc = (d: Date | null) => d?.toISOString() ?? null; + +describe("berlinZeitZuDate", () => { + it("rechnet im Winterhalbjahr mit +01:00 (MEZ)", () => { + expect(utc(berlinZeitZuDate(2026, 1, 15, 19, 30))).toBe("2026-01-15T18:30:00.000Z"); + }); + + it("rechnet im Sommerhalbjahr mit +02:00 (MESZ) — der eigentliche Fehler", () => { + expect(utc(berlinZeitZuDate(2026, 8, 5, 19, 30))).toBe("2026-08-05T17:30:00.000Z"); + }); + + it("trifft den Tag der Umstellung auf Sommerzeit", () => { + // Umstellung 2026: Sonntag, 29. März, 02:00 → 03:00. + expect(utc(berlinZeitZuDate(2026, 3, 29, 1, 0))).toBe("2026-03-29T00:00:00.000Z"); // noch MEZ + expect(utc(berlinZeitZuDate(2026, 3, 29, 4, 0))).toBe("2026-03-29T02:00:00.000Z"); // schon MESZ + }); + + it("trifft den Tag der Umstellung auf Winterzeit", () => { + // Umstellung 2026: Sonntag, 25. Oktober, 03:00 → 02:00. + expect(utc(berlinZeitZuDate(2026, 10, 25, 1, 0))).toBe("2026-10-24T23:00:00.000Z"); // noch MESZ + expect(utc(berlinZeitZuDate(2026, 10, 25, 4, 0))).toBe("2026-10-25T03:00:00.000Z"); // schon MEZ + }); + + it("behandelt Mitternacht korrekt (hour12:false liefert dort 24)", () => { + expect(utc(berlinZeitZuDate(2026, 8, 5, 0, 0))).toBe("2026-08-04T22:00:00.000Z"); + expect(utc(berlinZeitZuDate(2026, 1, 15, 0, 0))).toBe("2026-01-14T23:00:00.000Z"); + }); + + it("liefert für unsinnige Angaben null statt eines Invalid Date", () => { + expect(berlinZeitZuDate(Number.NaN, 1, 1, 0, 0)).toBeNull(); + }); + + it("stimmt mit der Berliner Ortszeit überein, aus der gerechnet wurde", () => { + // Rückprobe: Formatiert man das Ergebnis wieder in Berliner Ortszeit, muss + // dieselbe Uhrzeit herauskommen — für jeden Monat des Jahres. + const fmt = new Intl.DateTimeFormat("de-DE", { + timeZone: "Europe/Berlin", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + for (let monat = 1; monat <= 12; monat++) { + const d = berlinZeitZuDate(2026, monat, 15, 19, 30); + expect(d).not.toBeNull(); + expect(fmt.format(d as Date)).toBe("19:30"); + } + }); +}); diff --git a/app/src/lib/ris/provox.ts b/app/src/lib/ris/provox.ts index d52a73f..03ece42 100644 --- a/app/src/lib/ris/provox.ts +++ b/app/src/lib/ris/provox.ts @@ -17,6 +17,7 @@ import { createHash } from "node:crypto"; import type { RisAdapter, MeetingRef, FetchedMeeting, DocumentRef, FetchFn } from "./types.js"; import { makeRisGetFn } from "./fetch-wrapper.js"; import { decodeHtmlEntities } from "../text/html-entities.js"; +import { berlinZeitZuDate } from "./zeitzone"; // --------------------------------------------------------------------------- // HTML-Parsing-Hilfsfunktionen (pure, testbar ohne fetch) @@ -267,13 +268,16 @@ export function sha256Hex(text: string): string { // Datum-Parsing // --------------------------------------------------------------------------- -/** Parst "DD.MM.YYYY" und optionale Zeit "HH:MM" */ +/** + * Parst "DD.MM.YYYY" und optionale Zeit "HH:MM". + * Die Angabe ist deutsche Ortszeit — siehe zeitzone.ts. + */ function parseDeutschesDatum(datum: string, zeit?: string): Date | null { const match = datum.match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/); if (!match) return null; const [, d, m, y] = match; const [h, min] = (zeit ?? "00:00").split(":").map(Number); - return new Date(`${y}-${String(+m).padStart(2, "0")}-${String(+d).padStart(2, "0")}T${String(h ?? 0).padStart(2, "0")}:${String(min ?? 0).padStart(2, "0")}:00+01:00`); + return berlinZeitZuDate(Number(y), Number(m), Number(d), h ?? 0, min ?? 0); } /** Parst "Montag, 12. Mai 2026" (deutsches Langdatum) */ @@ -290,7 +294,7 @@ function parseDeutschesLangdatum(datum: string, zeit?: string): Date | null { const m = monate[monName]; if (!m) return null; const [h, min] = (zeit ?? "00:00").split(":").map(Number); - return new Date(`${y}-${m}-${String(+d).padStart(2, "0")}T${String(h ?? 0).padStart(2, "0")}:${String(min ?? 0).padStart(2, "0")}:00+01:00`); + return berlinZeitZuDate(Number(y), Number(m), Number(d), h ?? 0, min ?? 0); } /** Entfernt HTML-Tags und dekodiert Entities */ diff --git a/app/src/lib/ris/zeitzone.ts b/app/src/lib/ris/zeitzone.ts new file mode 100644 index 0000000..30b7bc8 --- /dev/null +++ b/app/src/lib/ris/zeitzone.ts @@ -0,0 +1,67 @@ +/** + * zeitzone.ts — Ortszeit Europe/Berlin in einen Zeitpunkt umrechnen. + * + * Ratsinformationssysteme geben Sitzungstermine als deutsche ORTSZEIT aus, ohne + * Zeitzonenangabe. Ein fest verdrahtetes "+01:00" liegt deshalb im gesamten + * Sommerhalbjahr eine Stunde daneben — eine Sitzung um 19:30 erschien als 18:30. + * + * Die Umrechnung geht über Intl statt über eine Tabelle: Die Regeln für die + * Zeitumstellung stehen damit dort, wo sie gepflegt werden (in der ICU-Datenbank + * der Laufzeit), und nicht in unserem Code. + * + * HINWEIS ZUR DOPPLUNG: `allris.ts` enthält dieselbe Rechnung noch einmal. Diese + * Datei zieht sie NICHT dort heraus, weil der ALLRIS-Parser bis nach dem Pitch + * unter Fix-Freeze steht (zwei Härtungsrunden haben dort je eine schwerere Lücke + * erzeugt, als sie geschlossen haben). Der geplante ALLRIS-Rewrite schwenkt auf + * dieses Modul um; bis dahin ist die Dopplung bewusst und dokumentiert. + */ + +const BERLIN_FORMATTER = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + hour12: false, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", +}); + +/** Wie weit liegt Berliner Ortszeit zu diesem Zeitpunkt vor UTC (in ms)? */ +function berlinOffsetMs(timestamp: number): number { + const parts = BERLIN_FORMATTER.formatToParts(new Date(timestamp)); + const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0"); + const asUtc = Date.UTC( + get("year"), + get("month") - 1, + get("day"), + // 24 statt 0 kommt bei hour12:false für Mitternacht vor. + get("hour") % 24, + get("minute"), + get("second") + ); + return asUtc - timestamp; +} + +/** + * Berliner Ortszeit → Date. Gibt null zurück, wenn die Angaben keinen gültigen + * Zeitpunkt ergeben. + * + * Zwei Durchläufe: Der erste Offset stammt aus einer noch ungenauen Instanz, der + * zweite korrigiert die Grenzfälle an den Umstellungsnächten. Ohne den zweiten + * Durchlauf läge eine Sitzung am Umstellungstag um eine Stunde daneben. + */ +export function berlinZeitZuDate( + y: number, + m: number, + d: number, + h: number, + min: number +): Date | null { + const naive = Date.UTC(y, m - 1, d, h, min); + if (!Number.isFinite(naive)) return null; + let ts = naive - berlinOffsetMs(naive); + ts = naive - berlinOffsetMs(ts); + const date = new Date(ts); + return Number.isNaN(date.getTime()) ? null : date; +}