Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions app/src/lib/ris/__tests__/zeitzone.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
10 changes: 7 additions & 3 deletions app/src/lib/ris/provox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) */
Expand All @@ -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 */
Expand Down
67 changes: 67 additions & 0 deletions app/src/lib/ris/zeitzone.ts
Original file line number Diff line number Diff line change
@@ -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;
}