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

176 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
plan: 03
type: tdd
wave: 2
depends_on: ["18-01"]
files_modified:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/tests/broker/reminderScheduler.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: [D-05, D-06, D-07]
must_haves:
truths:
- "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)"
artifacts:
- path: "apps/api/src/broker/reminderScheduler.ts"
provides: "all-day TZ now sourced from getHouseholdTimezone(db) (line ~247 rewire)"
- path: "apps/api/src/broker/outboxWorker.ts"
provides: "all-day TZ now sourced from getHouseholdTimezone(db) (lines ~501, ~607 rewire)"
key_links:
- from: "apps/api/src/broker/reminderScheduler.ts"
to: "apps/api/src/lib/householdTimezone.ts"
via: "import { getHouseholdTimezone }"
pattern: "getHouseholdTimezone\\(db\\)"
- from: "apps/api/src/broker/outboxWorker.ts"
to: "apps/api/src/lib/householdTimezone.ts"
via: "import { getHouseholdTimezone }"
pattern: "getHouseholdTimezone\\(db\\)"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing stored-TZ all-day tests for scheduler + outbox</name>
<files>apps/api/tests/broker/reminderScheduler.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first>
- apps/api/tests/broker/reminderScheduler.test.ts (lines ~656729: 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)
</read_first>
<behavior>
- 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).
</behavior>
<action>
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`.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<done>RED gate met: stored-TZ tests fail because the call sites are not yet rewired.</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — rewire all three all-day TZ call sites through getHouseholdTimezone(db)</name>
<files>apps/api/src/broker/reminderScheduler.ts, apps/api/src/broker/outboxWorker.ts</files>
<read_first>
- 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)
</read_first>
<action>
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`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts tests/broker/outboxWorker.test.ts</automated>
</verify>
<acceptance_criteria>
- `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.
</acceptance_criteria>
<done>GREEN gate met: stored TZ drives all three all-day sites; D-06 fallback + D-07 boundary intact.</done>
</task>
</tasks>
<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>
<verification>
- `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.
</verification>
<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>
<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-03-SUMMARY.md` when done.
</output>