176 lines
12 KiB
Markdown
176 lines
12 KiB
Markdown
---
|
||
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 ~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)
|
||
</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>
|