Files
familysync/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
T

8.0 KiB
Raw Blame History

Phase 18: Auto timezone detection and ability to change timezone - Context

Gathered: 2026-06-14 Status: Ready for planning

## Phase Boundary

Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser — instead of the implicit process.env.TZ fallback that all-day reminder computation relies on today.

Today the timezone is invisible: the server uses process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone (which is UTC inside Docker) purely for the all-day "9 AM local" reminder computation, while the browser uses its own local zone for write serialization and display. This phase stores a single household timezone, seeds it from the browser at setup, lets an admin change it, and routes the server-side all-day reminder computation through it.

In scope:

  • Store a single household timezone (new app_config key, e.g. household_timezone).
  • Auto-detect the browser IANA timezone and seed the stored value during the Phase 12 setup wizard / first run.
  • Admin-facing UI to view and change the household timezone (searchable IANA picker).
  • Route the server-side all-day reminder "9 AM local" computation through the stored timezone (overriding the process.env.TZ fallback when set).

Out of scope:

  • Per-member timezones (single household value only — see D-01).
  • Auto-overwrite of the stored timezone on login/travel/VPN drift (see D-03).
  • Changing display rendering or timed-event serialization, which stay browser-local and already-correct (see D-07).
## Implementation Decisions

Timezone scope

  • D-01: Single household-wide timezone, stored in app_config (one row, e.g. key household_timezone, IANA string value). Chosen over a per-member users.timezone column — the household is two people in the same location, and a single value matches the existing implicit single-TZ model. No new column or per-row scheduler logic.

Detection & drift

  • D-02: Auto-detect the browser IANA timezone (Intl.DateTimeFormat().resolvedOptions().timeZone) and use it to seed the stored value during the Phase 12 setup wizard / first run.
  • D-03: After seeding, the timezone changes only via the settings UI. Do NOT auto-overwrite the stored value on later login/detection differences (travel, VPN, new device). Silent drift would unexpectedly shift the "fires at 9 AM" guarantee. (Optional drift notice is allowed but not required — never an automatic write.)

Where to change it

  • D-04: Surface the timezone in the existing role-gated /admin Settings (Phase 10 requireAdmin boundary) and seed it from the Phase 12 setup wizard. It is a single household-wide setting, so admin-gating is the correct fit; server-side requireAdmin is the real boundary (client isAdmin is UX only, per Phase 10 D-03).

Behavioral boundary

  • D-05: The stored timezone becomes the source of truth for the server-side all-day "9 AM local" reminder computation (reminderScheduler.ts, outboxWorker.ts), replacing the bare process.env.TZ ?? Intl… lookup at those sites.
  • D-06: Fallback chain when household_timezone is unset (e.g. before the wizard seeds it, or on upgrade of an existing install): fall back to the current behavior — process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone. Nothing breaks before a value is stored; this is the backward-compat path.
  • D-07: Display rendering and timed-event write serialization stay browser-local and unchanged. This phase must NOT touch the already-correct browser-local write/display path (eventDateTime.ts, hydrateEvents.ts) — keep the blast radius to the all-day reminder computation only.

Claude's Discretion

  • Timezone picker UX: a searchable IANA dropdown (recorded default; planner/UI may refine). Validate the value is a real IANA zone before storing.
  • Exact app_config key name and the read/cache strategy for the stored value in the scheduler/outbox (e.g. read-per-run vs cached) — planner's call.
  • Whether to show a non-blocking "detected zone differs" notice on login (allowed per D-03, not required).

<canonical_refs>

Canonical References

Downstream agents MUST read these before planning or implementing.

Current timezone touchpoints (the code this phase rewires)

  • apps/api/src/broker/reminderScheduler.ts §~247 — serverTz lookup for the all-day branch; primary site to route through the stored timezone (D-05).
  • apps/api/src/broker/outboxWorker.ts §~501, §~607 — tz lookups feeding computeAlertInstantUtc for all-day alert instants; same rewire (D-05).
  • apps/api/src/broker/vevent.ts §~233315 — computeAlertInstantUtc: the pure 9 AM-local computation that consumes the tz; contract unchanged, only the tz it's fed changes.

Storage & settings surfaces

  • apps/api/src/db/schema.ts §282 — appConfig table (key/value/updatedAt); where the household_timezone key lives (D-01).
  • Phase 10 admin surface — apps/api/src/ /api/admin/* routes + requireAdmin, and the PWA /admin Settings route; where the change-tz UI attaches (D-04). See .planning/phases/10-admin-role-settings/10-CONTEXT.md.
  • Phase 12 setup wizard — .planning/phases/12-*/12-CONTEXT.md and its 12-UI-SPEC.md; where detection seeding hooks in (D-02). Note dependency ordering: Phase 12 may not be executed yet — planner should confirm and, if needed, make wizard seeding additive/optional so Phase 18 isn't blocked.

Browser-local path (do NOT modify — boundary guard)

  • apps/pwa/src/lib/eventDateTime.ts — browser-local write serialization (D-07).
  • apps/pwa/src/lib/hydrateEvents.ts — display hydration (D-07).

</canonical_refs>

<code_context>

Existing Code Insights

Reusable Assets

  • appConfig table (schema.ts:282) — already exists from Phase 10; store the household timezone as a single key/value row, no migration needed for a new key (only seed/read logic).
  • requireAdmin gating + /admin Settings route (Phase 10) — reuse directly for the change-tz control; no new auth surface.
  • Setup wizard scaffolding (Phase 12) — reuse for first-run detection + seeding.
  • Intl.DateTimeFormat().resolvedOptions().timeZone — already the detection primitive used server-side; reuse client-side for seeding.

Established Patterns

  • Server is the real authorization boundary; client role flags are UX only (Phase 10 D-03) — apply to any tz-write endpoint.
  • All-day reminder math is isolated in computeAlertInstantUtc (pure, tz-arg) — the phase only needs to change what tz is passed, not the math.
  • Backward-compat via additive app_config keys + fallback defaults (Phase 10 / Phase 11 style) — keep the process.env.TZ ?? Intl fallback (D-06).

Integration Points

  • All-day reminder fire path: reminderScheduler.ts and outboxWorker.ts both independently read tz — both must route through the same stored-value accessor (single helper to avoid drift between the two sites).
  • Admin Settings (read/write tz) ↔ app_config ↔ scheduler/outbox read path.

</code_context>

## Specific Ideas
  • The "9 AM local" all-day reminder guarantee is the load-bearing reason this setting exists — correctness of that fire time is the success measure.
  • Keep the change tightly scoped to the all-day reminder computation; the browser-local timed/display path was deliberately fixed earlier and must not regress.
## Deferred Ideas
  • Per-member timezones — only relevant if household members live in different zones; revisit as its own phase if that ever becomes true (would add users.timezone + per-row scheduler logic).
  • Driving display/timed reminders off the stored tz — broader behavior change deliberately excluded (D-07) to protect the already-correct browser-local path.

Phase: 18-auto-timezone-detection-and-ability-to-change-timezone Context gathered: 2026-06-14