Skip to content

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

  1. Drei Field-Typen, keine Ueberlappung. Ein Datum ist entweder ein UTC-Instant, ein Kalender-Datum oder ein Location-gebundener Termin. Nie alles drei gleichzeitig.
  2. Temporal statt JS Date. JS Date ist semantisch kaputt (Doppelnatur Instant/Wall-Clock). Temporal hat genau die Typen die wir brauchen.
  3. Location-TZ ist Dateneigenschaft, nicht Display-Detail. Wenn der Disponent “10:00 Lissabon” meint, wird das so gespeichert — inklusive TZ-Information. Geht nie verloren.
  4. API-Boundary spricht Wall-Clock + TZ fuer located, UTC-ISO fuer alles andere. Idiotensicher: explizit zwei Felder statt eleganter Kombination in einem String.
  5. Feature-Code hat eine einheitliche Form. Temporal-Objekte ueberall, Framework abstrahiert die Konvertierung DB↔API↔UI.
  6. “Lokal” ist mehrdeutig — nennen wir’s Location-TZ. Klare Begriffe verhindern Bugs.

Begriffe (konsequent im Code nutzen)

BegriffBedeutungWofuer
System-TZServer-Uhr (immer UTC)Existiert nicht als Konzept — Server-Code nutzt nie System-TZ
Tenant-TZDefault-TZ des MandantenReports, “Heute”, scheduled Cleanup-Jobs
User-TZAnzeige-TZ des einzelnen Users (Profil)Personliche Anzeige — “meine Aktivitaeten”
Location-TZTZ am Ort eines DatumsTermine, 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" }
    • at ist Wall-Clock ohne Offset (nicht “+01:00”, nicht “Z”)
    • tz ist 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:

LayerVerhalten bei timestamp ohne locatedByVerhalten mit locatedBy
ZodISO-DatetimeWall-Clock-Regex + Pflicht-tz-Feld
DB-WrapperUTC-Spalte direktWall-Clock+tz → UTC beim Write, UTC+tz → Wall-Clock+tz beim Read
JSON-SerializerUTC-ISOZwei Felder { at, tz }
UI-Renderer<DateTimeInput><LocatedDateTimePicker>
Search-IndexerUTC fuer SortUTC fuer Sort + formatierte Wall-Clock fuer Text-Search
Audit-DisplayUser-TZLocation-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)

RuntimeNative Temporal
Chrome/Edge 144+, Firefox 139+
Chrome/Firefox Android 147+/149+
Safari (Desktop + iOS)
Samsung Internet
React Native / Hermes❌ (voraussichtlich lange)
Node.js / Bunteilweise, V8-abhaengig

Global ~69% — nicht Baseline. Fuer universelle Kumiko-Apps Polyfill noetig.

Polyfill-Setup

packages/framework/src/time/polyfill.ts
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)

TypSpalte
timestamptimestamptz
datedate
locatedTimestampfoo_at timestamptz, foo_tz text

MySQL / MariaDB

TypSpalte
timestampTIMESTAMP (UTC-stored intern — nicht DATETIME!)
dateDATE
locatedTimestampfoo_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

TypSpalte
timestampTEXT (ISO) oder INTEGER (epoch) — via Dialect-Wrapper
dateTEXT (YYYY-MM-DD)
locatedTimestampfoo_at TEXT, foo_tz TEXT

ctx.tz-API (Framework-geliefert)

Eine einheitliche API fuer alle TZ-Operationen. Nutzt Temporal-Types.

// Accessors — einfache Werte
ctx.tz.tenant // string — "Europe/Berlin"
ctx.tz.user // string — "Europe/Berlin" (oder User-Override)
// Jetzt / Heute
ctx.tz.now() // Temporal.Instant
ctx.tz.nowIn(tz) // Temporal.ZonedDateTime
ctx.tz.today(tz) // Temporal.PlainDate
ctx.tz.todayRange(tz) // { start: Instant, end: Instant } — fuer DB-Queries
// Parsing / Konvertierung
ctx.tz.parse(wallClock, tz) // ("2026-04-03T10:00", "Europe/Lisbon") → ZonedDateTime
ctx.tz.toInstant(zdt) // ZonedDateTime → Instant (UTC)
ctx.tz.toLocatedJson(zdt) // ZonedDateTime → { at, tz } (JSON-Form)
ctx.tz.fromLocatedJson(obj) // { at, tz } → ZonedDateTime
// Geocoding
ctx.tz.fromAddress(address) // Address-Objekt → IANA-TZ via geo-tz
// Optional, braucht @kumiko/geo-tz-Paket

Feature-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 pickupAddress via ctx.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 CEST
  • tz: "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 usable

Geo-TZ-Lookup

Adresse → IANA-TZ noetig beim Eingeben eines Pickup-Addresses.

Optionen

MethodeWieWann
geo-tz-Lib (offline lat/lng → TZ)~30 MB DatenpaketGenau, offline, kostenlos
Geocoding-API (Google/Mapbox)HTTP-CallGenau + liefert lat/lng gleich mit, aber online + kosten
Country → TZ-MapTrivial-LookupNicht 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-basiert

v1-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 uebersprungen

Pflicht-Testtage

  • DST Spring-Forward (Europa 2026: 29.03.): 02:30 Europe/Berlin existiert nicht
  • DST Fall-Back (Europa 2026: 25.10.): 02:30 Europe/Berlin existiert 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.timezone IANA-validiert beim Set
  • locatedBy zeigt auf existierendes Feld des Typs "tz" — sonst Boot-Fehler
  • piiEncrypted: 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 nicht UTC ist

Framework-Ausbau

AusbauWarum
temporal-polyfill als DependencyUniversal-Support
Polyfill-Init im Framework-BootEinmalig, idempotent
type: "timestamp", "date", "tz", locatedBy-MarkerEntity-Type-System
locatedTimestamp(name) HelperDX fuer Entity-Autoren
DB-Dialect-Wrapper emittet richtige Spalten-TypenCross-Dialect-Storage
JSON-Serializer { at, tz } fuer locatedTimestampAPI-Boundary
Zod-Validatoren fuer jeden TypSchema-Validierung
ctx.tz-Context-Accessor + Helper-APIKonsistente 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-CodeFalle verhindern
Boot-Validierung (locatedBy-Referenz, TZ-ENV, etc.)Fail-fast
GeoTzProvider-InterfaceOptional 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/Lisbon
delivery_at 2026-04-04T16:00:00Z (18:00 CEST → UTC)
delivery_tz Europe/Berlin

Lesen (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

ViewerPickup-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

  1. temporal-polyfill-Integration + Framework-Boot
  2. ✅ Entity-Field-Types: "timestamp", "date", "tz", locatedBy-Marker, locatedTimestamp(name)-Helper
  3. ✅ Zod-Validatoren pro Typ
  4. ✅ DB-Dialect-Wrapper: richtige Spalten pro Typ + Dialekt — instant() customType (Sprint F)
  5. ✅ DB-Wrapper-Konvertierung: Wall-Clock+tz ↔ UTC transparent — flattenLocatedTimestamp / rehydrateLocatedTimestamp
  6. ✅ JSON-Serializer/-Deserializer (Standard fuer timestamp/date, custom fuer locatedTimestamp)
  7. ctx.tz-Helper-API
  8. ✅ Lint-Regeln gegen new Date()scripts/guard-no-new-date.ts aktiviert in Sprint F.5
  9. Boot-Validierungen
  10. <DateTimeInput>, <LocatedDateTimePicker>, <DateInput> UI-Komponenten
  11. Scheduled-Jobs TZ-aware (in core-jobs)
  12. CI-Matrix mit 5 TZs, DST-Tag-Tests
  13. GeoTzProvider-Interface + @kumiko/geo-tz-offline-Paket (optional)
  14. ✅ Migration: bestehende Entities bleiben wie sie sind, neue koennen locatedTimestamp nutzen — Sprint F atomic-Switch hat bestehende timestamp()-Spalten transparent auf instant() umgestellt
  15. ✅ beammycar-Sample-Update: Order verwendet locatedTimestamp("pickup") + locatedTimestamp("delivery")
  16. Post-Todos:
    • Address-TZ-Sync-Dialog (v2)
    • Recurring Events mit TZ-Regeln (eigenes Feature)
    • type:"date" semantisch fixen: heute aliased auf instant() (TIMESTAMPTZ), sollte Temporal.PlainDate mit PG date-Spalte sein (siehe TODO in table-builder.ts case "date").