Alle Artikel

KI-Modelle

Zeitzonen-Code richtig testen

DeepSeek und Claude machen Zeitzonencode richtig: Intl.formatToParts + Date.UTC + iterative DST-Konvergenz. Code-Vergleich, Gegencheck zum Nachbauen.

8 min min

Teil 01b der Reihe „5 KI-Modelle, dieselbe Aufgabe". ~8 Min Lesezeit. Stand: Juni 2026.

Auf einen Blick

  • Der saubere Weg: Intl.DateTimeFormat.formatToParts() um Zeitkomponenten in der Zielzone auszulesen + Date.UTC() um daraus einen UTC-Instant zu bauen. Nie lokale Getter, nie toLocaleString als Parsing-Trick.
  • DeepSeek v4-flash und v4-pro machen es beide richtig — mit leicht anderen Ansätzen. Beide bestehen alle vier Zeitzonen + unabhängigen Gegencheck.
  • Claude Opus löst einen Edge-Case eleganter: berührende Fenster (09–12 + 12–18) werden automatisch gemergt — kein Sonderfall nötig.
  • Der unabhängige Gegencheck ist einfach zu bauen: UTC-Input, UTC-Erwartung, von Hand gerechnet. Hier ist der Code.

Du liest Teil 01b. Teil 01a erklärt das Phänomen (Qwen, GLM, Mistral) und warum falsch-grüne Tests gefährlicher als rote sind. Zurück zu Teil 01a →


Was die funktionierenden Modelle anders machen

DeepSeek (v4-flash und v4-pro) und Claude Opus haben alle vier Zeitzonen und den unabhängigen Gegencheck bestanden. Das Prinzip ist dasselbe bei allen dreien — die Umsetzung unterscheidet sich in Details.

Das Grundprinzip:

  1. Intl.DateTimeFormat.formatToParts() benutzen, um Zeitkomponenten (Jahr, Monat, Tag, Stunde, Minute) in der Zielzone aus einem UTC-Instant zu lesen
  2. Diese Komponenten mit Date.UTC() zu einem neuen UTC-Instant zusammenbauen — nicht mit new Date(year, month, day, ...), das wäre lokaler Konstruktor
  3. Die Differenz = UTC-Offset der Zielzone an diesem Instant
  4. Für nextChange: iterativ konvergieren, um DST-Grenzen sauber zu treffen

Keine Maschinen-TZ im Spiel. Der Code läuft auf Berlin-Server, UTC-Server, Kolkata-Server identisch.


DeepSeek v4-flash: der Offset-Konvergenz-Ansatz

/root/deepseek/business-hours.ts — der billige Tier. Flash ist ~12× billiger als Pro und kostet für diesen Eval-Lauf $0,10. Er macht es trotzdem richtig.

Schritt 1: UTC-Offset aus formatToParts ableiten

// Zeilen 80–104 (DeepSeek v4-flash)
function getUTCOffsetMs(utcInstant: Date, timeZone: string): number {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
    hour12: false,
  }).formatToParts(utcInstant);

  const p = (type: string): number =>
    Number(parts.find((x) => x.type === type)?.value ?? 0);

  const localAsUTC = Date.UTC(
    p("year"), p("month") - 1, p("day"),
    p("hour"), p("minute"), p("second"),
  );
  return localAsUTC - utcInstant.getTime();
}

Was passiert: formatToParts liest die Zeitkomponenten in der Zielzone aus dem UTC-Instant. Das sind echte Berliner Stunden und Minuten, nicht Maschinenzeit. Dann kommt Date.UTC(...) — das behandelt diese Komponenten als UTC, nicht als Lokalzeit. Die Differenz zwischen diesem konstruierten UTC-Wert und dem echten UTC-Instant ist der Offset. Sauber, ohne Maschinen-TZ, ohne Regex.

Der "en-CA"-Locale-Trick: Kanada liefert "YYYY-MM-DD"-Format, das formatToParts zuverlässig in year/month/day-Parts zerlegt. Etwas defensive Programmierung, aber solid.

Schritt 2: nextChange über DST-Grenzen iterativ konvergieren

// Zeilen 119–132 (DeepSeek v4-flash)
function createDateAtLocalTime(local, time, daysAhead, timeZone) {
  const [h, m] = time.split(":").map(Number);

  // Erste Annäherung: lokale Wandzeit als ob UTC
  let approx = new Date(
    Date.UTC(local.year, local.month - 1, local.day + daysAhead, h, m, 0),
  );

  // Zwei Iterationen konvergieren auch an DST-Grenzen
  for (let i = 0; i < 2; i++) {
    const offset = getUTCOffsetMs(approx, timeZone);
    approx = new Date(
      Date.UTC(local.year, local.month - 1, local.day + daysAhead, h, m, 0) - offset,
    );
  }
  return approx;
}

Warum zwei Iterationen? An einer DST-Grenze ändert sich der Offset genau um die DST-Differenz (meist 1 Stunde). Die erste Annäherung landet eine Stunde daneben, weil sie noch den alten Offset benutzt. Die zweite Iteration nimmt den Offset am neuen Zeitpunkt — der ist jetzt korrekt. Für Geschäftszeiten reichen zwei; theoretisch könnten sehr ungewöhnliche Zeitzonen mehr brauchen, aber das ist Randfall.

DeepSeek v4-flash hat außerdem als einziges Modell seine eigenen Grenzen explizit dokumentiert:

/**
 * Limitations:
 *  – Slots are assumed to start and end within the same calendar day
 *    (no overnight slots like 22:00–02:00).
 *  – Non-existent wall-clock times caused by DST "spring-forward" are
 *    handled sensibly: the function will snap to what a wall clock would
 *    show at the next valid instant.
 */

Das ist Selbst-Verifikation auf Architektur-Ebene: das Modell weiß, was es nicht kann. Das ist wertvoller als blindes Vertrauen in eigene Tests.


DeepSeek v4-pro: GMT-String-Parsing als Variante

/root/deepseek-pro/getBusinessHoursStatus.ts — geht einen anderen Weg für den Offset:

// Zeilen 99–117 (DeepSeek v4-pro)
function getOffset(date: Date, timeZone: string): number {
  const fmt = new Intl.DateTimeFormat('en-US', {
    timeZone,
    timeZoneName: 'longOffset',
  });
  const parts = fmt.formatToParts(date);
  const tzPart = parts.find((p) => p.type === 'timeZoneName')!;

  if (tzPart.value === 'GMT' || tzPart.value === 'UTC') return 0;

  const match = tzPart.value.match(/GMT([+-])(\d{2}):(\d{2})/);
  if (!match) return 0;

  const sign = match[1] === '+' ? 1 : -1;
  return sign * (parseInt(match[2], 10) * 60 + parseInt(match[3], 10));
}

timeZoneName: 'longOffset' liefert Strings wie "GMT+02:00" oder "GMT-05:00". Regex darauf parsen, fertig. Etwas fragiler (Locale-Strings können sich je nach Engine unterscheiden), aber in der Praxis zuverlässig für gängige IANA-Zonen.

Pro hat außerdem einen Sicherheitsnetz-Check eingebaut: nach der Konversion prüft er, ob die lokalen Komponenten stimmen. Wenn nicht, korrigiert er explizit:

// Zeilen 142–151 (DeepSeek v4-pro)
const check = getLocalParts(result, timeZone);
if (check.hours !== hours || check.minutes !== minutes) {
  const diffMin = (hours - check.hours) * 60 + (minutes - check.minutes);
  result = new Date(result.getTime() + diffMin * 60_000);
}

Das ist ein expliziter Assertions-Mechanismus im Produktions-Code: konvertieren, prüfen, korrigieren. Mehr Laufzeit, aber mehr Selbstvertrauen.


Claude Opus: der elegantere Ansatz

/root/anon/businessHours.ts — löst das Problem von einer anderen Richtung. Statt für jeden Slot separat eine nextChange-Zeit zu berechnen, sammelt Opus alle Fenster-Grenzen der nächsten Tage als UTC-Instants ein und sucht dann die erste, an der der Status kippt:

// Zeilen 179–208 (Claude Opus)
const boundaries: number[] = [];
for (let i = 0; i <= LOOKAHEAD_DAYS; i++) {
  const cal = new Date(Date.UTC(base.year, base.month - 1, base.day + i));
  const y = cal.getUTCFullYear();
  const mo = cal.getUTCMonth() + 1;
  const d = cal.getUTCDate();
  const wd = cal.getUTCDay();

  for (const [start, end] of intervalsFor(hours, wd)) {
    boundaries.push(wallTimeToUtc(y, mo, d, start, timeZone).getTime());
    boundaries.push(wallTimeToUtc(y, mo, d, end, timeZone).getTime());
  }
}
boundaries.sort((a, b) => a - b);

// Erste Grenze, an der der Status kippt
for (const t of boundaries) {
  if (t > now && isOpenAt(new Date(t), hours, timeZone) !== open) {
    return { open, nextChange: new Date(t) };
  }
}

Alle Boundaries sammeln, sortieren, durchlaufen — und an jedem Punkt prüfen, ob der Status tatsächlich wechselt. Dieser Schritt — isOpenAt(new Date(t), hours, timeZone) !== open — macht den Unterschied beim berührenden-Fenster-Edge-Case.

Der berührende-Fenster-Edge-Case — DeepSeek-pro vs. Opus

Angenommen, ein Laden hat Öffnungszeiten 09:00–12:00 und 12:00–18:00 — zwei Fenster, die sich bei 12:00 berühren, ohne Lücke. Anfrage um 11:00.

Was sollte nextChange sein?

Der Laden ist durchgehend offen von 09:00 bis 18:00. Der Status ändert sich erst um 18:00. Die korrekte Antwort ist 18:00.

const HOURS = {
  monday: [["09:00", "12:00"], ["12:00", "18:00"]]
};
// Anfrage: Mo 11:00 Berlin (Sommer, UTC+2) = 09:00Z
const res = getBusinessHoursStatus(new Date("2026-06-15T09:00:00Z"), HOURS, "Europe/Berlin");
// Erwartet:
// res.open === true
// res.nextChange.toISOString() === "2026-06-15T16:00:00.000Z"  // 18:00 Berlin Sommer

Claude Opus liefert 16:00Z (= 18:00 Berlin). Weil isOpenAt am Boundary 10:00Z (= 12:00 Berlin) prüft — und dort ist der Laden noch offen (12:00 ist Startpunkt des zweiten Fensters, das halboffene Intervall [12:00, 18:00) enthält 12:00). Status bleibt open, also kein Wechsel. Nächster Boundary, der wirklich kippt: 18:00.

DeepSeek v4-pro würde 10:00Z (= 12:00 Berlin) zurückgeben — das Schließen des ersten Fensters. Technisch korrekt nach seiner Logik (das erste Fenster endet), aber semantisch ein Schein-Wechsel: der Laden bleibt faktisch offen.

Das ist der Unterschied, den ich als „9 vs 9,5" eingestuft habe. Kein Bug bei DeepSeek — aber Opus denkt eine Ebene tiefer über die Semantik nach. Was bedeutet nextChange eigentlich? Der erste Zeitpunkt, an dem sich das Intervall ändert? Oder der erste Zeitpunkt, an dem der Nutzer etwas Anderes erleben würde? Opus versteht die zweite Variante.

Sauberere Input-Validierung

Opus hat außerdem sauberere Eingabe-Prüfung als alle anderen:

// Zeilen 52–60 (Claude Opus)
function parseHHMM(value: string): number {
  const m = /^(\d{1,2}):(\d{2})$/.exec(value);
  if (!m) throw new Error(`Ungültige Zeitangabe: "${value}"`);
  const h = Number(m[1]);
  const min = Number(m[2]);
  if (min > 59 || h > 24 || (h === 24 && min !== 0)) {
    throw new Error(`Ungültige Zeitangabe: "${value}"`);
  }
  return h * 3600 + min * 60;
}

"24:00" als Tagesende ist erlaubt (klassischer Sonderfall in Geschäftszeiten-Systemen), "25:00" wirft einen Fehler. Kein anderes Modell hat das explizit behandelt. Bei den anderen kann man "99:00" übergeben, ohne Fehler.


Der unabhängige Gegencheck — zum Nachbauen

Das ist die einfachste Sache, die man nach einem KI-generierten Code-Review tun kann. Dauert 15 Minuten, rettet den nächsten Produktionsvorfall:

import { getBusinessHoursStatus } from "./getBusinessHoursStatus";

const HOURS = {
  monday:    [["09:00", "12:00"], ["13:00", "18:00"]],
  tuesday:   [["09:00", "18:00"]],
  wednesday: [["09:00", "18:00"]],
  thursday:  [["09:00", "18:00"]],
  friday:    [["09:00", "18:00"]],
};

function check(
  label: string,
  inputISO: string,
  expectedOpen: boolean,
  expectedNextChangeISO: string
) {
  const res = getBusinessHoursStatus(new Date(inputISO), HOURS, "Europe/Berlin");
  const openOk = res.open === expectedOpen;
  const changeOk = res.nextChange.toISOString() === expectedNextChangeISO;
  console.log(`${openOk && changeOk ? "✅" : "❌"} ${label}`);
  if (!openOk)
    console.log(`   open:       got ${res.open}, want ${expectedOpen}`);
  if (!changeOk)
    console.log(`   nextChange: got ${res.nextChange.toISOString()}, want ${expectedNextChangeISO}`);
}

// Mo 15.01.2024, 11:30 Berlin (Winter, UTC+1) = 10:30Z → Mittagspause → zu
// Nächste Öffnung 13:00 Berlin = 12:00:00 UTC
check(
  "Mittagspause Winter",
  "2024-01-15T10:30:00Z",
  false,
  "2024-01-15T12:00:00.000Z"
);

// Fr 28.03.2026 Abend → übers Wochenende + DST-Sprung Sa Nacht
// Mo 30.03.2026 09:00 Berlin ist Sommer (UTC+2) = 07:00:00 UTC
check(
  "DST-Sprung übers WE",
  "2026-03-27T17:30:00Z",
  false,
  "2026-03-30T07:00:00.000Z"
);

// Mo 15.06.2026 10:00 Berlin (Sommer, UTC+2) = 08:00Z → geöffnet
// Schließt 12:00 Berlin = 10:00:00 UTC (Mittagspause)
check(
  "Offen Sommer",
  "2026-06-15T08:00:00Z",
  true,
  "2026-06-15T10:00:00.000Z"
);

// So 16.06.2026 14:00 Berlin (Sommer) → Wochenende, kein Eintrag
// Nächste Öffnung Mo 09:00 Berlin = 07:00:00 UTC
check(
  "Wochenende → Montag",
  "2026-06-15T12:00:00Z",
  false,
  "2026-06-16T07:00:00.000Z"
);

Dann:

TZ=UTC npx tsx gegencheck.ts
TZ=America/New_York npx tsx gegencheck.ts

Wenn beide Läufe dieselben Ergebnisse liefern: Code sauber. Wenn nicht: der Fehler liegt im Code, nicht in der Maschine. Kein Rauschen.

Die Erwartungswerte sind von Hand gerechnet. Berliner Geschäftszeiten + IANA-Zeitzonendatenbank. Nichts davon wurde aus dem Code abgeleitet — das ist der Punkt.


Was man mitnimmt

Zeitzonencode ist keine Frage der Erfahrung. Es ist eine Frage der Disziplin beim Testen. Vier Regeln, die kein Modell automatisch einhält:

1. UTC-Inputs und UTC-Erwartungen. "2024-01-15T10:30:00Z" mit Z, nicht ohne. Das Z fixiert den Zeitpunkt unabhängig von der Maschine. Ohne Z ist der Test maschinenabhängig.

2. Testsuite unter mehreren Maschinen-TZ. TZ=UTC npm test und TZ=Asia/Kolkata npm test kosten 30 Sekunden und entlarven sofort, ob Code oder Tests verschieben. Kolkata ist besonders gut: +5:30, kein ganzzahliger Offset, deckt Off-by-Half-Hour-Bugs auf.

3. Unabhängige Erwartungswerte. Wenigstens für die kritischen Cases: von Hand rechnen, hartkodieren, nicht vom Code ableiten. Den Code davor nicht ansehen — dann ist der Check unbestechlich.

4. Lokale Getter suchen. getHours(), getDate(), getDay() ohne UTC-Präfix in Zeitzonenkontext sind fast immer ein Bug. getUTCHours(), getUTCDate() sind explizit. new Date(date.toLocaleString(...)) ist fast immer falsch.

Nichts davon ist KI-spezifisch. Es ist die Sorgfalt, die guter Code immer gebraucht hat — KI liefert sie nur nicht automatisch mit. Wer weiß, dass das passiert, weiß, wie er dagegen testet. Und spart sich den Anruf vom Kunden, der in Kolkata sitzt.


Häufige Fragen

Warum formatToParts statt toLocaleString? formatToParts liefert strukturierte Komponenten (Jahr, Monat, Tag usw.) als Array, was präzises Parsing ohne Regex und ohne Locale-Abhängigkeiten erlaubt. toLocaleString liefert einen formatierten String, den new Date() als lokale Zeit parst — das ist die Quelle des Bugs in Qwen, GLM und Mistral.

Warum zwei Iterationen für die DST-Konvergenz? An einer DST-Grenze ändert sich der Offset plötzlich (meist um 1 Stunde). Die erste Annäherung berechnet den UTC-Instant mit dem alten Offset und landet dadurch eine Stunde daneben. Die zweite Iteration benutzt den Offset am neuen Zeitpunkt und korrigiert das. Für Standard-Zeitzonen (Europa, Amerika, Asien) reichen zwei; bei exotischen Zonen könnte man drei nehmen.

Was ist der Unterschied zwischen DeepSeek v4-flash und v4-pro auf dieser Aufgabe? Beide bestehen alle vier Zeitzonen und den Gegencheck. Der Pro-Ansatz hat einen eingebauten Assertions-Check nach der Konversion (vergleicht, ob die gebauten Zeitkomponenten stimmen, und korrigiert explizit). Flash ist robuster dokumentiert (explizite Limitations-Sektion). Für praktische Zwecke sind beide gleichwertig auf dieser Aufgabe.

Wann würde der berührende-Fenster-Edge-Case in der Praxis auftreten? Immer wenn ein Kalender-System zwei aufeinanderfolgende Buchungsslots oder Öffnungszeiten-Fenster hat, die nahtlos ineinander übergehen — z. B. 09–12 Vor- und 12–18 Nachmittag als separate Einträge. Systeme, die nextChange für Navigations-Hinweise nutzen („geöffnet bis…") würden falsch anzeigen.

Gilt das nur für TypeScript/JavaScript? Das Pattern toLocaleString + lokale Getter ist JS-spezifisch. Aber das Grundproblem — lokale Zeitkomponenten statt UTC-Instants — taucht in jeder Sprache auf. In Python: datetime.now() statt datetime.utcnow(). In Java: new Date() statt Instant. Das Testprinzip (UTC-Inputs, UTC-Erwartungen, mehrere Maschinen-TZ) ist universell.


Das war Teil 01b. Zurück zu Teil 01a (das Phänomen) → · Zur Reihen-Übersicht →

Nächster Teil: „Selbstbewusst falsch" — Mistral meldet grünen Haken für Tests, die nie gelaufen sind. Und was das über KI-Selbstverifikation als CI-Gate sagt.

Fragen zu diesem Thema?

Ruf an. Wir erklären gerne.

04481 99 79 00 00
04481 99 79 00 00