ADR 0005: Multi-tenant tijdzone (per-user weergavezone)
- Status: geaccepteerd
- Datum: 2026-07-31
- Beslissers: Thomas van Minnen
- Issue: #3017 · voortgekomen uit discussion #1619
- Branch:
feat/multi-tenant-timezone
Context
De opslag is al zone-agnostisch: de API draait strict op UTC en slaat op in UTC
(env.DEFAULT_TIMEZONE = 'UTC', Settings.defaultZone in luxonHelper.ts). Het
probleem zat puur in de render-laag: tijden werden geformatteerd zonder expliciete
zone en leunden op een globale default (Europe/Amsterdam). Zodra een gebruiker of tenant
in een andere zone zit, lopen e-mails, agenda-weergave en activiteit-virtuals stilzwijgend
verkeerd.
Beslissing
Pure per-user weergavezone. Alle tijdweergave gebeurt in de zone van de kijkende/
ontvangende gebruiker (user.timezone), met een vaste fallback-keten:
user.timezone→ tenant-settingtenantTimezone→Europe/Amsterdam
De opslag blijft UTC. De tenant-zone is enkel de default bij het aanmaken van een user en de fallback in de keten; er is bewust géén organisator-/tenant-zone-regel per weergave.
Server (apps/api)
user.timezoneis een persisted veld (was een hardcoded virtual). Default bij aanmaak uit de tenant-setting (prepareNewUserForCreate, best-effort), IANA-gevalideerd op update (isValidIanaTimezone), en toegevoegd aan de user-projecties (incl.MINIMAL/EXTERNAL, nodig voor e-mail-ontvangers).- Tenant-default als
setting-keytenantTimezone(géén veld op het gedeelde Tenant-model, géén settings→directory-sync). Instelbaar in de backoffice bij Algemene instellingen; geëxposeerd naar de FE viafindAppSettings(tenant.app.tenantTimezone). - E-mails renderen per ontvanger in diens zone. De Handlebars-helpers
date/getDayNamelezen de zone uitoptions.data.root.recipient.timezone. Belangrijke bugfix: ze lazen eerstthis.data.root— in een helper isthisde context, dus dat resolvde nooit en viel stil terug op Amsterdam; per-ontvanger-render werkte daardoor feitelijk niet. - weekly-activity-overview is een tenant-brede broadcast (één render) → rendert in de
tenant-zone (
getTenantTimezone), niet per ontvanger. - Backfill van bestaande users op
Europe/Amsterdamvia de processorbackfill-user-timezone. - De activity-virtuals
startDateString/roomClosedDatetime(die in de servertijd renderden) zijn verwijderd;enrichActivityRoomInfolevert nu rauwe tijden (opensAt/closesAt).
Frontend (apps/frontend)
De FE rendert client-side (browser doet geen impliciete conversie meer). Eén gedeelde bron —
TimezoneService (shared/services/timezone/) — met de fallback-keten en UTC-veilig parsen.
Twee pipes:
| Pipe | Voor | Zone |
|---|---|---|
zonedDate |
Elke opgeslagen datum/tijd (activiteit, kamer opent, sentAt, aangemaakt, start-/publiceerdatum) | Kijkerszone (DST-correct via formatDate + berekende offset) |
timezoneLabel |
IANA-zone leesbaar tonen in dropdowns | n.v.t. (Europe/Amsterdam → Europe/ Amsterdam) |
Regel: elke datum die uit een opgeslagen instant komt → zonedDate (drop-in voor | date).
Geen aparte “kalenderdatum in UTC”-pipe — zie Alternatieven voor waarom. Voor niet-DI-contexten
(route-breadcrumb) bestaat formatInUserZone(). user.timezone is instelbaar op de profielpagina
en in de backoffice user-detail (naast Status), met dezelfde fallback voor de default.
Gevolgen
- Positief: één bron van waarheid voor de weergavezone; e-mails en schermen kloppen per
ontvanger; opslag blijft ongewijzigd UTC. Eén pipe (
zonedDate) voor álle datums — geen per-veld keuze, dus geen kans op de verkeerde pipe of op een instant die als vorige dag toont. - Negatief: elke datumweergave hernoemt
| date→| zonedDate; ~een-op-een meer pipe-imports in componenten. - Risico’s: de dag-stabiliteit van kale kalenderdatums rust op de aanname “tenants zijn UTC+
(Europa)” (zie Alternatieven). De pipe is
pure: een live zone-wijziging wordt pas na reload zichtbaar (bewust).
Alternatieven
- Tenant-/organisator-zone per weergave — afgewezen: vergt cross-tenant zone-reads en een
isExternal-vertakking; de kijkende user is de enige zone-bron die je altijd hebt. - Alles browser-lokaal laten op de FE — eerst gekozen als pragmatisch uitstel, later vervangen: browser-lokaal wijkt af van de (correcte) server/mail-render zodra apparaat-zone ≠ profiel-zone, en negeert de gekozen profielzone.
- Angular
DatePipemet vastetimezone-offset — afgewezen: een vaste offset breekt over DST.zonedDateberekent de offset per tijdstip, dus DST-correct. - Aparte kalenderdatum-pipe die rauw in UTC rendert (
plainDate) — kort ingevoerd, weer afgevoerd. Doel was kalenderdatums dag-stabiel houden voor elke kijker, maar (1)aangemaakt/publish-startDatezijn echte instants (new Date()/ Mongoosetimestamps), géén UTC-middernacht — UTC-render toonde daar de vórige dag bij een laat-avond-tijdstip; (2) de wél op UTC-middernacht opgeslagen date-picker-datums vallen voor elke UTC+-kijker samen met de kijkerszone-render. Onder de aanname “alle tenants UTC+ (Europa)” voegt de aparte pipe dus niets toe en kost hij een per-veld keuze-footgun. Vervalt die aanname (tenant op/ten westen van UTC), dan is een echte kalenderdatum-oplossing (UTC-render óf date-only opslag/transport) alsnog nodig.
Huidige status (2026-07-31)
Af & geverifieerd (AOT-build groen; FE-suite 1247 tests; API-suite groen; timezone-specs):
server-laag compleet; e-mail-fix; tenant-setting + dropdowns; zonedDate + timezoneLabel; alle
zichtbare datum-/tijdweergaven uit de API op zonedDate (kijkerszone).
Checkpoints: b72db422c (backend/setup) · 7ff4dda97 (client-side sweep) · 572a75325
(zone-labels; introduceerde plainDate, in deze revisie weer verwijderd t.g.v. één-pipe-aanpak).
Openstaand:
- Bewust niet gezoned (geen API-UTC-instant): geconstrueerde periode-labels (week/maand-kiezer,
backoffice-agenda-viewtitel), analytics dag-bucket, en lokaal gekozen datepicker-datums
(recurrence
until, mail-composer geplande verzenddatum). - Nog te doen:
/klaar(lint + volledige tests + mergemain+ push); nog geen PR. - QA-checklist van alle datumweergaven: privé-artifact
https://claude.ai/code/artifact/829e05cf-47c4-40bd-9921-f09a53aca007.