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;
+}