Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.0 KiB
Phase 18: Auto timezone detection and ability to change timezone - Context
Gathered: 2026-06-14 Status: Ready for planning
## Phase BoundaryMake 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_configkey, 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.TZfallback 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).
Timezone scope
- D-01: Single household-wide timezone, stored in
app_config(one row, e.g. keyhousehold_timezone, IANA string value). Chosen over a per-memberusers.timezonecolumn — 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
/adminSettings (Phase 10requireAdminboundary) and seed it from the Phase 12 setup wizard. It is a single household-wide setting, so admin-gating is the correct fit; server-siderequireAdminis the real boundary (clientisAdminis 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 bareprocess.env.TZ ?? Intl…lookup at those sites. - D-06: Fallback chain when
household_timezoneis 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_configkey 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 —serverTzlookup for the all-day branch; primary site to route through the stored timezone (D-05).apps/api/src/broker/outboxWorker.ts§~501, §~607 —tzlookups feedingcomputeAlertInstantUtcfor all-day alert instants; same rewire (D-05).apps/api/src/broker/vevent.ts§~233–315 —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 —appConfigtable (key/value/updatedAt); where thehousehold_timezonekey lives (D-01).- Phase 10 admin surface —
apps/api/src//api/admin/*routes +requireAdmin, and the PWA/adminSettings 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.mdand its12-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
appConfigtable (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).requireAdmingating +/adminSettings 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_configkeys + fallback defaults (Phase 10 / Phase 11 style) — keep theprocess.env.TZ ?? Intlfallback (D-06).
Integration Points
- All-day reminder fire path:
reminderScheduler.tsandoutboxWorker.tsboth 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.
- 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