reminderScheduler.ts all-day branch computes the 9 AM-local alert instant using the stored household_timezone when set (D-05)
outboxWorker.ts both all-day branches (create + update) compute the alert instant using the stored household_timezone when set (D-05)
When household_timezone is unset, all three sites fall back to process.env.TZ ?? Intl — existing all-day tests pass unmodified (D-06)
Both files route through the single getHouseholdTimezone accessor — the read/fallback logic is NOT duplicated at the call sites (D-05)
path
provides
apps/api/src/broker/reminderScheduler.ts
all-day TZ now sourced from getHouseholdTimezone(db) (line ~247 rewire)
path
provides
apps/api/src/broker/outboxWorker.ts
all-day TZ now sourced from getHouseholdTimezone(db) (lines ~501, ~607 rewire)
from
to
via
pattern
apps/api/src/broker/reminderScheduler.ts
apps/api/src/lib/householdTimezone.ts
import { getHouseholdTimezone }
getHouseholdTimezone(db)
from
to
via
pattern
apps/api/src/broker/outboxWorker.ts
apps/api/src/lib/householdTimezone.ts
import { getHouseholdTimezone }
getHouseholdTimezone(db)
Rewire the three all-day "9 AM local" timezone lookups — one in `reminderScheduler.ts` (line ~247)
and two in `outboxWorker.ts` (lines ~501 and ~607) — to read the stored timezone through the
single `getHouseholdTimezone(db)` accessor, replacing the bare
`process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` reads.
Purpose: D-05 makes the stored household_timezone the source of truth for the all-day reminder
computation, and mandates ONE shared accessor at both sites (no duplicated read/fallback). D-06
keeps the exact process.env.TZ ?? Intl behavior when nothing is stored, so the existing all-day
tests pass unmodified. D-07 is the hard boundary: this plan must NOT touch display/timed-write paths.
Output: three rewired call sites + new stored-TZ tests; existing all-day tests green unchanged.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-CONTEXT.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md
@.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md
@apps/api/src/lib/householdTimezone.ts
Task 1: RED — failing stored-TZ all-day tests for scheduler + outbox
apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts
- apps/api/tests/broker/reminderScheduler.test.ts (lines ~656–729: existing all-day tests that pin process.env.TZ='America/New_York' — DO NOT modify them; they must keep passing via D-06 fallback)
- apps/api/tests/broker/outboxWorker.test.ts (how its mock db chain is built for the all-day branches)
- apps/api/src/broker/vevent.ts §240 (computeAlertInstantUtc(eventDateStr, leadDays, tz): Date — contract unchanged; assert the 9 AM-local instant for the stored zone)
- apps/api/src/lib/householdTimezone.ts (the accessor whose stored-value path you are now exercising)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 5: why existing tests stay green; new tests mock db to return the app_config row)
- reminderScheduler all-day: when the mock db ALSO returns { key: 'household_timezone', value: 'America/Chicago' }, the computed alert instant equals 9 AM America/Chicago for the event date (a different UTC instant than 9 AM America/New_York would give).
- outboxWorker all-day (create branch): with stored 'America/Chicago', the create-branch alert instant is 9 AM Chicago for the event date.
- outboxWorker all-day (update branch): with stored 'America/Chicago', the update-branch alert instant is 9 AM Chicago for the event date.
- Backward-compat (assert, do not modify): the pre-existing all-day tests that pin process.env.TZ and return NO app_config row still pass (fallback fires).
Add new stored-TZ test cases alongside the existing all-day tests. Where the existing tests mock
the Drizzle query for events, extend the mock so the household_timezone SELECT resolves to a row
with value 'America/Chicago' (match the accessor's query shape so getHouseholdTimezone returns it).
Assert the resulting UTC instant equals 9 AM Chicago for the event date (compute the expected UTC
via a fixed reference, e.g. using computeAlertInstantUtc with 'America/Chicago' directly, or an
explicit ISO instant). Do NOT alter the existing process.env.TZ-pinned cases. Run the two suites;
confirm the NEW cases FAIL (code still reads process.env.TZ, so stored 'America/Chicago' has no
effect). Commit as `test(18-03): add failing stored-TZ all-day tests for scheduler + outbox`.
pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts 2>&1 | grep -qi "fail" && echo RED-OK
- New stored-TZ cases exist in both test files and FAIL before the rewire (code still reads process.env.TZ).
- The existing process.env.TZ-pinned all-day cases are unchanged (no edits to those test bodies).
- `test(18-03): ...` commit precedes the rewire commit.
RED gate met: stored-TZ tests fail because the call sites are not yet rewired.
Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)
apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts
- apps/api/src/broker/reminderScheduler.ts (line ~247 serverTz lookup; line ~256 computeAlertInstantUtc call; db already imported line 46)
- apps/api/src/broker/outboxWorker.ts (lines ~501 + ~607 tz lookups; runOutboxDrain line ~710; db already imported line 31)
- apps/api/src/lib/householdTimezone.ts (import getHouseholdTimezone)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (reminderScheduler + outboxWorker sections: exact replacement lines)
In `reminderScheduler.ts`: add `import { getHouseholdTimezone } from '../lib/householdTimezone.js'`
and replace the line-247 `const serverTz = process.env.TZ ?? Intl…` with
`const serverTz = await getHouseholdTimezone(db);` (the enclosing function is already async / awaits
DB queries; `db` is the module import on line 46). Leave the line-256 computeAlertInstantUtc call
unchanged — serverTz is still a string.
In `outboxWorker.ts`: add the same import. Replace BOTH bare `const tz = process.env.TZ ?? Intl…`
lookups (~501 and ~607) with `const tz = await getHouseholdTimezone(db);`. `db` is the module import
on line 31; `runOutboxDrain` is async. (If both branches are reachable in one pass and lint allows,
you may hoist a single `const tz = await getHouseholdTimezone(db)` to the top of the all-day block
and reuse it — but do NOT duplicate the read/fallback inline; route through the accessor only.)
HARD BOUNDARY (D-07): do not open, import from, or modify `apps/pwa/src/lib/eventDateTime.ts` or
`apps/pwa/src/lib/hydrateEvents.ts`, and do not change any timed-event serialization or display path.
Run both suites; iterate to GREEN. Confirm the existing process.env.TZ-pinned tests still pass.
Commit as `feat(18-03): route all-day reminder TZ through stored household_timezone`.
pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts
- `grep -c "getHouseholdTimezone(db)" apps/api/src/broker/reminderScheduler.ts` ≥ 1 and same for outboxWorker.ts ≥ 1.
- No bare `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` remains at the rewired all-day sites (assert: `grep -v '^\s*\*' apps/api/src/broker/reminderScheduler.ts | grep -c "process.env.TZ ?? Intl"` → 0; same for outboxWorker.ts). The fallback now lives only inside getHouseholdTimezone.
- D-07 boundary: `git diff --name-only` for this plan does NOT include `apps/pwa/src/lib/eventDateTime.ts` or `apps/pwa/src/lib/hydrateEvents.ts` (assert they are absent from the changed-files list).
- New stored-TZ tests GREEN; existing process.env.TZ all-day tests still GREEN (unmodified).
- `pnpm --filter @familysync/api test -- --run` fully green; `tsc --noEmit` clean.
- `feat(18-03): ...` commit follows the RED commit.
GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.
<threat_model>
Trust Boundaries
Boundary
Description
scheduler/outbox → DB
trusted server worker reads the validated stored timezone to compute fire times
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-18-08
Tampering
stored TZ fed to computeAlertInstantUtc could be garbage
mitigate
The value can only have been written via Plan 02's IANA-validated PUT/seed; the accessor returns either a validated stored string or the D-06 fallback. No unvalidated value reaches computeAlertInstantUtc.
T-18-09
Tampering (regression)
accidental change to display/timed-write path
mitigate
D-07 boundary enforced as an acceptance criterion: changed-files list must exclude eventDateTime.ts + hydrateEvents.ts; no timed-event serialization touched. Blast radius confined to the all-day branches.
T-18-10
Denial of Service
extra DB read per tick at 3 call sites
accept
PK lookups on a 60s interval; negligible (RESEARCH A1). Read-per-run keeps changes propagating within one tick without a worker restart.
T-18-SC
Tampering
npm/pip/cargo installs
mitigate
No new packages this plan (RESEARCH Package Legitimacy Audit: zero installs).
</threat_model>
- `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` green (all 244+ existing tests, including unmodified all-day cases).
- D-07: changed-files exclude the two PWA display-path files.
<success_criteria>
All three all-day TZ sites route through getHouseholdTimezone(db); no duplicated read/fallback.
D-05 source-of-truth, D-06 backward-compat, D-07 boundary all satisfied.
RED then GREEN commits present.
</success_criteria>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md` when done.