Timezones (Framework-Infrastruktur)
Timezone-Handling fuer Multi-Tenant-Apps. Deckt die drei echten Faelle ab: UTC-Instants (“wann passiert”), Kalender-Daten (“Geburtstag”), und Location-gebundene Termine (“10:00 Lissabon”). Letzteres ist der Fall an dem die meisten Frameworks scheitern.
Kein Feature, sondern Framework-Infra wie Outbox. Wirkt auf Entity-Definitionen, DB-Layer, API, UI-Renderer gleichermassen.
Prinzipien
- Drei Field-Typen, keine Ueberlappung. Ein Datum ist entweder ein UTC-Instant, ein Kalender-Datum oder ein Location-gebundener Termin. Nie alles drei gleichzeitig.
- Temporal statt JS Date. JS Date ist semantisch kaputt (Doppelnatur Instant/Wall-Clock). Temporal hat genau die Typen die wir brauchen.
- Location-TZ ist Dateneigenschaft, nicht Display-Detail. Wenn der Disponent “10:00 Lissabon” meint, wird das so gespeichert — inklusive TZ-Information. Geht nie verloren.
- API-Boundary spricht Wall-Clock + TZ fuer located, UTC-ISO fuer alles andere. Idiotensicher: explizit zwei Felder statt eleganter Kombination in einem String.
- Feature-Code hat eine einheitliche Form. Temporal-Objekte ueberall, Framework abstrahiert die Konvertierung DB↔API↔UI.
- “Lokal” ist mehrdeutig — nennen wir’s Location-TZ. Klare Begriffe verhindern Bugs.
Begriffe (konsequent im Code nutzen)
| Begriff | Bedeutung | Wofuer |
|---|---|---|
| System-TZ | Server-Uhr (immer UTC) | Existiert nicht als Konzept — Server-Code nutzt nie System-TZ |
| Tenant-TZ | Default-TZ des Mandanten | Reports, “Heute”, scheduled Cleanup-Jobs |
| User-TZ | Anzeige-TZ des einzelnen Users (Profil) | Personliche Anzeige — “meine Aktivitaeten” |
| Location-TZ | TZ am Ort eines Datums | Termine, Abholungen, Meetings — Datenbestandteil |
Der Begriff “lokal” wird in Code und Docs vermieden — zu mehrdeutig.
Drei Field-Typen
Typ 1: type: "timestamp" — UTC-Instant
Fuer Ereignisse die zu einem bestimmten Augenblick in der Zeit passieren. Keine Location noetig. Beispiele: createdAt, updatedAt, actualPickupAt, loginAt.
- Temporal-Entsprechung:
Temporal.Instant - DB-Storage:
timestamptz(PG) /TIMESTAMP(MySQL, in UTC) / TEXT-ISO (SQLite) - JSON-Form:
"2026-04-03T08:00:00Z"— Standard UTC-ISO via.toJSON() - UI-Komponente:
<DateTimeInput>— zeigt in User-TZ, speichert UTC
Typ 2: type: "date" — Kalender-Datum, kein TZ
Fuer Daten die kein Uhrzeit-Konzept haben. Beispiele: birthday, contractDate, holiday.
- Temporal-Entsprechung:
Temporal.PlainDate - DB-Storage:
date(alle Dialekte) - JSON-Form:
"2026-04-03"— Standard Kalender-ISO via.toJSON() - UI-Komponente:
<DateInput>— reiner Datums-Picker, keine TZ-Logik
Typ 3: type: "locatedTimestamp" — Wall-Clock + Location-TZ
Fuer Termine die an einem Ort stattfinden und deren Wall-Clock-Bedeutung wichtig ist. Beispiele: pickupAt, deliveryAt, meetingAt.
- Temporal-Entsprechung:
Temporal.ZonedDateTime - DB-Storage: zwei Spalten —
foo_at(timestamptz, UTC) +foo_tz(text, IANA) - JSON-Form: zwei Felder —
{ fooAt: "2026-04-03T10:00:00", fooTz: "Europe/Lisbon" }atist Wall-Clock ohne Offset (nicht “+01:00”, nicht “Z”)tzist IANA-Name
- UI-Komponente:
<LocatedDateTimePicker>— spezieller Picker der nie JS Date benutzt
Die Zwei-Felder-JSON-Form ist die idiotensichere Variante. Analog zu money (amount + currency). Jeder der die JSON sieht, versteht sie. Keine Sonder-Parser fuer [Europe/Lisbon]-Bracket-Notation noetig.
locatedTimestamp-Helper und locatedBy-Marker
Der Helper erzeugt die zwei Felder und den Marker:
function locatedTimestamp(name: string) { return { [`${name}At`]: { type: "timestamp", locatedBy: `${name}Tz` }, [`${name}Tz`]: { type: "tz" }, // validiert IANA };}
// Verwendung:r.entity("order", { fields: { createdAt: { type: "timestamp" }, ...locatedTimestamp("pickup"), // pickupAt + pickupTz ...locatedTimestamp("delivery"), // deliveryAt + deliveryTz birthday: { type: "date" }, },});Der Marker locatedBy ist die einzige Stelle an der “zusammengehoerig” steht. Alle anderen Layer lesen ihn:
| Layer | Verhalten bei timestamp ohne locatedBy | Verhalten mit locatedBy |
|---|---|---|
| Zod | ISO-Datetime | Wall-Clock-Regex + Pflicht-tz-Feld |
| DB-Wrapper | UTC-Spalte direkt | Wall-Clock+tz → UTC beim Write, UTC+tz → Wall-Clock+tz beim Read |
| JSON-Serializer | UTC-ISO | Zwei Felder { at, tz } |
| UI-Renderer | <DateTimeInput> | <LocatedDateTimePicker> |
| Search-Indexer | UTC fuer Sort | UTC fuer Sort + formatierte Wall-Clock fuer Text-Search |
| Audit-Display | User-TZ | Location-TZ |
type: "tz" ist ein Mini-Typ: validiert als gueltiger IANA-Name via Intl.supportedValuesOf("timeZone").
Temporal als Fundament — mit Polyfill
Temporal-Typen sind das mentale Modell dieses Dokuments. Das ist kein Zufall — Temporal wurde genau fuer diese drei Faelle designt.
Stand der Plattform-Unterstuetzung (2026-04)
| Runtime | Native Temporal |
|---|---|
| Chrome/Edge 144+, Firefox 139+ | ✅ |
| Chrome/Firefox Android 147+/149+ | ✅ |
| Safari (Desktop + iOS) | ❌ |
| Samsung Internet | ❌ |
| React Native / Hermes | ❌ (voraussichtlich lange) |
| Node.js / Bun | teilweise, V8-abhaengig |
Global ~69% — nicht Baseline. Fuer universelle Kumiko-Apps Polyfill noetig.
Polyfill-Setup
if (typeof globalThis.Temporal === "undefined") { await import("temporal-polyfill/global");}temporal-polyfill(FullCalendar) — ~25 KB, universal, kompatibel mit Hermes/iOS- Polyfill wird beim Framework-Boot einmal installiert
- Auf Chrome/Firefox wird’s ein No-Op (Temporal ist schon da)
- Sobald Safari/Hermes Temporal shippen → Polyfill wird transparent ueberfluessig
Storage pro Dialect
PostgreSQL (ideal)
| Typ | Spalte |
|---|---|
timestamp | timestamptz |
date | date |
locatedTimestamp | foo_at timestamptz, foo_tz text |
MySQL / MariaDB
| Typ | Spalte |
|---|---|
timestamp | TIMESTAMP (UTC-stored intern — nicht DATETIME!) |
date | DATE |
locatedTimestamp | foo_at TIMESTAMP, foo_tz VARCHAR(64) |
Achtung: DATETIME in MySQL/MariaDB ist TZ-naiv und wird NICHT verwendet. Der DB-Dialect-Wrapper (db-dialect.md) emittet TIMESTAMP.
SQLite
| Typ | Spalte |
|---|---|
timestamp | TEXT (ISO) oder INTEGER (epoch) — via Dialect-Wrapper |
date | TEXT (YYYY-MM-DD) |
locatedTimestamp | foo_at TEXT, foo_tz TEXT |
ctx.tz-API (Framework-geliefert)
Eine einheitliche API fuer alle TZ-Operationen. Nutzt Temporal-Types.
// Accessors — einfache Wertectx.tz.tenant // string — "Europe/Berlin"ctx.tz.user // string — "Europe/Berlin" (oder User-Override)
// Jetzt / Heutectx.tz.now() // Temporal.Instantctx.tz.nowIn(tz) // Temporal.ZonedDateTimectx.tz.today(tz) // Temporal.PlainDatectx.tz.todayRange(tz) // { start: Instant, end: Instant } — fuer DB-Queries
// Parsing / Konvertierungctx.tz.parse(wallClock, tz) // ("2026-04-03T10:00", "Europe/Lisbon") → ZonedDateTimectx.tz.toInstant(zdt) // ZonedDateTime → Instant (UTC)ctx.tz.toLocatedJson(zdt) // ZonedDateTime → { at, tz } (JSON-Form)ctx.tz.fromLocatedJson(obj) // { at, tz } → ZonedDateTime
// Geocodingctx.tz.fromAddress(address) // Address-Objekt → IANA-TZ via geo-tz // Optional, braucht @kumiko/geo-tz-PaketFeature-Code-Perspektive — das Versprechen
Business-Code hat eine konsistente Form. Kein Verzweig nach TZ-Typ.
Handler — Write
r.writeHandler("order.create", schema, async (event, ctx) => { await ctx.db.insert("order", { customerName: event.customerName, pickupAt: event.pickupAt, // { at, tz } vom API pickupTz: event.pickupTz, // (schon drin) pickupAddress:event.pickupAddress, }); // Framework konvertiert Wall-Clock+tz zu UTC fuer pickup_at-Spalte, // speichert tz in pickup_tz-Spalte});Handler — Query
r.queryHandler("order.upcoming", schema, async (q, ctx) => { const range = ctx.tz.todayRange(ctx.tz.tenant); return ctx.db.select().from(orders) .where(between(orders.pickupAt, range.start, range.end)) .orderBy(orders.pickupAt); // orders.pickupAt ist UTC-Spalte → Range-Query direkt // Reihenfolge = absolute Zeit-Reihenfolge (was fast immer gewuenscht ist)});Hook
r.hook("preSave", "order", async (ctx, changes) => { if (changes.pickupAt) { const zdt = ctx.tz.fromLocatedJson(changes.pickupAt); // Temporal.ZonedDateTime const instant = ctx.tz.toInstant(zdt);
if (Temporal.Instant.compare(instant, ctx.tz.now()) < 0) { throw new ValidationError("Pickup darf nicht in der Vergangenheit sein"); } }});Was der Feature-Code NIE tut
new Date(userInput)— verboten, Browser-TZ-Falle.toISOString()auf User-Input- Manuelle Offset-Rechnung
- Eigene Date-Math jenseits
ctx.tz
Lint-Regel: new Date() ausserhalb von @kumiko/framework/time/* → Fehler.
UI-Komponenten
Zwei klar getrennte Komponenten, der Renderer picked automatisch anhand Field-Type.
<DateTimeInput> — fuer type: "timestamp"
- Input im User-TZ-Kontext (“meine Sicht”)
- Output: ISO-UTC-String
- Nutzt Temporal intern — nie
new Date() - Fuer Ereignisse die “wirklich wann passiert sind”
<LocatedDateTimePicker> — fuer type: "locatedTimestamp"
- Input: separate Date + Time + TZ-Picker (oder TZ aus Address abgeleitet)
- Internal State:
{ wallClockStr: string, tz: string }— niemals JS Date - Output:
{ at: "2026-04-03T10:00:00", tz: "Europe/Lisbon" } - Sichtbarer TZ-Hinweis im UI (“Zeit lokal am Abholort”)
- Bei Address-Change: TZ kann automatisch aktualisiert werden aus
pickupAddressviactx.tz.fromAddress()
<DateInput> — fuer type: "date"
- Reiner Datums-Picker
- Keine TZ-Logik
- Output: “YYYY-MM-DD”
Address-TZ-Sync — Edge-Case, nicht v1
Wenn pickupAddress sich aendert und damit TZ wechselt: Default-Verhalten ist Wall-Clock beibehalten (UTC wird neu berechnet). Im Feld passiert in der Praxis aber nie, weil niemand zwischen Laendern wechselt beim bestehenden Auftrag.
v1: kein UI-Dialog, Framework behaelt Wall-Clock bei. Bei Bedarf Operator-Fix. v2: Dialog “Wall-Clock behalten oder Moment behalten?” wenn der Fall tatsaechlich aufschlaegt.
Scheduled Jobs TZ-aware
r.job("weeklyReport", { cron: "0 9 * * 1", // Montag 09:00 tz: "tenant", // in Tenant-TZ}, handler);tz: "tenant"— pro Tenant wird der Cron-Tick in dessen TZ berechnet. Ein Tenant in Tokyo bekommt Montag 09:00 JST, einer in Berlin Montag 09:00 CESTtz: "utc"— server-weit, nicht tenant-spezifisch- DST-Wechsel korrekt behandelt (via Temporal-basiertem Cron)
Implementation: Scheduler iteriert Tenants, berechnet je naechsten Fire-Time als Temporal.ZonedDateTime in Tenant-TZ, schedulet entsprechend.
Sortierung und Filterung
Sort
orderBy(pickupAt)— sortiert nach UTC = absolute Zeit-Reihenfolge- Fuer Pickup-Listen (und 99% aller Faelle) richtig
Edge-Case: Sort nach Wall-Clock
“Alle 10:00-Pickups weltweit, nach Wall-Clock sortiert” — braucht Computed Column oder Query-Time-Conversion. Nicht v1 — Framework bietet’s nicht, Feature-Autor kann’s bei Bedarf selbst via computed column.
Filter-Range
const { start, end } = ctx.tz.todayRange(ctx.tz.tenant);// start/end sind Temporal.Instant, DB-Column ist UTC → direkt usableGeo-TZ-Lookup
Adresse → IANA-TZ noetig beim Eingeben eines Pickup-Addresses.
Optionen
| Methode | Wie | Wann |
|---|---|---|
geo-tz-Lib (offline lat/lng → TZ) | ~30 MB Datenpaket | Genau, offline, kostenlos |
| Geocoding-API (Google/Mapbox) | HTTP-Call | Genau + liefert lat/lng gleich mit, aber online + kosten |
| Country → TZ-Map | Trivial-Lookup | Nicht genau bei USA/Russland/etc. — nur Notloesung |
Als optionales Paket
@kumiko/framework → Interface `GeoTzProvider`@kumiko/geo-tz-offline → geo-tz-basiert, ~30 MB@kumiko/geo-tz-google → Google-API-basiertv1-Default: kein Auto-Lookup. Form zeigt TZ-Dropdown neben Adress-Input. Wer Auto-Lookup will, installiert eines der Pakete.
Migration bestehender Daten
Bestehende timestamp-Felder bleiben timestamp (UTC-Instants). Kein Handlungsbedarf.
Neue locatedTimestamp-Felder werden bei Entity-Update via Migration hinzugefuegt — zwei neue Spalten (foo_at, foo_tz). Bestandsdaten: je nach Szenario Backfill oder Default.
Tenant-TZ: neue Spalte tenant.timezone mit Default "UTC", TenantAdmin setzt korrekt.
Test-Strategie — das wo’s scheitern wuerde
TZ-Tests muessen in jeder TZ gruen sein. Sonst hat man “passed in CI (UTC), broken in Berlin (CEST)”.
CI-Matrix
matrix: TZ: - UTC - America/Los_Angeles - Europe/Berlin - Asia/Tokyo - Pacific/Apia # UTC+13/+14, hat 2011 einen Tag uebersprungenPflicht-Testtage
- DST Spring-Forward (Europa 2026: 29.03.):
02:30 Europe/Berlinexistiert nicht - DST Fall-Back (Europa 2026: 25.10.):
02:30 Europe/Berlinexistiert zweimal - US-DST-Randdaten
- Pacific/Apia Datumsgrenze — simulierten Tag-Sprung-Test
Deterministic Time
vi.setSystemTime(...) fuer jeden Test der Zeit-abhaengige Logik hat. Kein new Date() in Tests.
Kern-Szenarien
- Disponent in Berlin-CI erstellt Order mit Pickup Lissabon 10:00 → DB hat UTC+tz korrekt → Read in Tokyo-CI zeigt “10:00 (Lissabon)”, NICHT “18:00 JST”
- DST-Sprung Lissabon: Pickup 10:00 am 28.10 vs 29.10 → UTC-Werte unterschiedlich, Wall-Clock gleich
- Scheduled Job in Tenant-TZ: Tokyo-Tenant bekommt Montag 09:00 JST, Berlin 09:00 CEST — auch in Fall-Back-Nacht korrekt
- Pacific/Apia-CI: Tests gruen
Boot-Validierungen
tenant.timezoneIANA-validiert beim SetlocatedByzeigt auf existierendes Feld des Typs"tz"— sonst Boot-FehlerpiiEncrypted: true+type: "timestamp"→ Fehler (macht semantisch keinen Sinn)- Lint-Regel:
new Date()ausserhalb Framework-Time-Modul - Lint-Regel:
.getHours() / .getMinutes()auf JS Date direkt im Feature-Code → Fehler - Warnung beim Server-Start wenn
TZ-ENV nichtUTCist
Framework-Ausbau
| Ausbau | Warum |
|---|---|
temporal-polyfill als Dependency | Universal-Support |
| Polyfill-Init im Framework-Boot | Einmalig, idempotent |
type: "timestamp", "date", "tz", locatedBy-Marker | Entity-Type-System |
locatedTimestamp(name) Helper | DX fuer Entity-Autoren |
| DB-Dialect-Wrapper emittet richtige Spalten-Typen | Cross-Dialect-Storage |
JSON-Serializer { at, tz } fuer locatedTimestamp | API-Boundary |
| Zod-Validatoren fuer jeden Typ | Schema-Validierung |
ctx.tz-Context-Accessor + Helper-API | Konsistente Feature-Nutzung |
<DateTimeInput>, <LocatedDateTimePicker>, <DateInput> | UI-Komponenten, eigene, nie JS Date |
Scheduled-Jobs TZ-aware (tz: "tenant" | "utc") | Business-Day-korrekte Schedules |
Lint-Regeln gegen new Date() im Feature-Code | Falle verhindern |
Boot-Validierung (locatedBy-Referenz, TZ-ENV, etc.) | Fail-fast |
GeoTzProvider-Interface | Optional Auto-Lookup aus Adresse |
Was NICHT im Scope ist
- Bracket-Notation-JSON (
"2026-04-03T10:00:00+01:00[Europe/Lisbon]"): technisch RFC 9557-Standard, aber nicht idiotensicher — unsere zwei Felder sind robuster. - Address-TZ-Sync-Dialog: Edge-Case der in der Praxis nie aufschlaegt.
- Sort nach Wall-Clock: Edge-Case, Computed Column ist Escape-Hatch.
- Recurring Events mit TZ-Regeln (iCal-style): eigenes Feature spaeter, nicht hier.
- Automatic DST-Notifications (“deine Termine morgen verschieben sich um eine Stunde”): App-Business-Logic, nicht Framework.
- Historische TZ-Daten (z.B. Deutschland pre-1980): Temporal deckt’s ab, Tests covern aktuelle Regeln.
beammycar als konkretes Beispiel
Szenario: Disponent (Berlin) legt Fahrt an, Pickup Lissabon 10:00 am 03.04.2026, Delivery Muenchen 18:00 am 04.04.2026.
Entity
r.entity("order", { fields: { customerName: { type: "text" }, pickupAddress: { type: "embedded", schema: addressSchema }, ...locatedTimestamp("pickup"), deliveryAddress:{ type: "embedded", schema: addressSchema }, ...locatedTimestamp("delivery"),
actualPickupAt: { type: "timestamp", optional: true }, // reiner UTC-Moment actualDeliveryAt: { type: "timestamp", optional: true }, },});Schreiben (Disponent-Form)
// POST /order.create{ "customerName": "Mueller GmbH", "pickupAddress": { "city": "Lisboa", "country": "PT", ... }, "pickupAt": "2026-04-03T10:00:00", "pickupTz": "Europe/Lisbon", "deliveryAddress": { "city": "Muenchen", "country": "DE", ... }, "deliveryAt": "2026-04-04T18:00:00", "deliveryTz": "Europe/Berlin"}DB-Zeile (nach Framework-Konvertierung)
pickup_at 2026-04-03T09:00:00Z (10:00 WEST → UTC)pickup_tz Europe/Lisbondelivery_at 2026-04-04T16:00:00Z (18:00 CEST → UTC)delivery_tz Europe/BerlinLesen (beliebiger Viewer)
// GET /order/123{ "customerName": "Mueller GmbH", "pickupAt": "2026-04-03T10:00:00", "pickupTz": "Europe/Lisbon", // Information ist bewahrt, unabhaengig vom Viewer "deliveryAt": "2026-04-04T18:00:00", "deliveryTz": "Europe/Berlin"}UI-Anzeige
| Viewer | Pickup-Anzeige |
|---|---|
| Disponent in Berlin | ”10:00 (Lissabon)” mit Optional-Zeile ”= 11:00 deine Zeit” |
| Fahrer grade in Lissabon | ”10:00” (Location-TZ = User-TZ, redundant) |
| Customer-Service Tokyo | ”10:00 (Lissabon) = 18:00 deine Zeit” |
Fahrer klickt “abgeholt” um 10:23 Lissabon
// POST /order/123/pickup// actualPickupAt ist reiner UTC-Timestamp — Frontend sendet ISO-Z{ "actualPickupAt": "2026-04-03T09:23:17Z" }UI-Anzeige spaeter
“Termin: 10:00 (Lissabon) | Abgeholt: 10:23 (Lissabon)”
— UI formatiert actualPickupAt (reiner Timestamp) im pickupTz-Kontext, fuer visuellen Vergleich.
Build-Reihenfolge
- ✅
temporal-polyfill-Integration + Framework-Boot - ✅ Entity-Field-Types:
"timestamp","date","tz",locatedBy-Marker,locatedTimestamp(name)-Helper - ✅ Zod-Validatoren pro Typ
- ✅ DB-Dialect-Wrapper: richtige Spalten pro Typ + Dialekt —
instant()customType (Sprint F) - ✅ DB-Wrapper-Konvertierung: Wall-Clock+tz ↔ UTC transparent —
flattenLocatedTimestamp/rehydrateLocatedTimestamp - ✅ JSON-Serializer/-Deserializer (Standard fuer timestamp/date, custom fuer locatedTimestamp)
- ✅
ctx.tz-Helper-API - ✅ Lint-Regeln gegen
new Date()—scripts/guard-no-new-date.tsaktiviert in Sprint F.5 - Boot-Validierungen
<DateTimeInput>,<LocatedDateTimePicker>,<DateInput>UI-Komponenten- Scheduled-Jobs TZ-aware (in core-jobs)
- CI-Matrix mit 5 TZs, DST-Tag-Tests
GeoTzProvider-Interface +@kumiko/geo-tz-offline-Paket (optional)- ✅ Migration: bestehende Entities bleiben wie sie sind, neue koennen
locatedTimestampnutzen — Sprint F atomic-Switch hat bestehendetimestamp()-Spalten transparent auf instant() umgestellt - ✅ beammycar-Sample-Update: Order verwendet
locatedTimestamp("pickup")+locatedTimestamp("delivery") - Post-Todos:
- Address-TZ-Sync-Dialog (v2)
- Recurring Events mit TZ-Regeln (eigenes Feature)
type:"date"semantisch fixen: heute aliased aufinstant()(TIMESTAMPTZ), sollteTemporal.PlainDatemit PGdate-Spalte sein (siehe TODO intable-builder.tscase "date").