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-setting tenantTimezoneEurope/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.timezone is 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-key tenantTimezone (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 via findAppSettings (tenant.app.tenantTimezone).
  • E-mails renderen per ontvanger in diens zone. De Handlebars-helpers date/getDayName lezen de zone uit options.data.root.recipient.timezone. Belangrijke bugfix: ze lazen eerst this.data.root — in een helper is this de 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/Amsterdam via de processor backfill-user-timezone.
  • De activity-virtuals startDateString/roomClosedDatetime (die in de servertijd renderden) zijn verwijderd; enrichActivityRoomInfo levert 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/AmsterdamEurope/ 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 DatePipe met vaste timezone-offset — afgewezen: een vaste offset breekt over DST. zonedDate berekent 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-startDate zijn echte instants (new Date() / Mongoose timestamps), 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 + merge main + push); nog geen PR.
  • QA-checklist van alle datumweergaven: privé-artifact https://claude.ai/code/artifact/829e05cf-47c4-40bd-9921-f09a53aca007.