Files
2026-06-18 22:21:38 -04:00

159 lines
9.7 KiB
Markdown

---
phase: 18-auto-timezone-detection-and-ability-to-change-timezone
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- apps/api/src/lib/householdTimezone.ts
- apps/api/tests/lib/householdTimezone.test.ts
autonomous: true
requirements: [D-05, D-06]
must_haves:
truths:
- "getHouseholdTimezone(db) returns the stored household_timezone value when the app_config row is present (D-05)"
- "getHouseholdTimezone(db) falls back to process.env.TZ when no row is stored, then to Intl.DateTimeFormat().resolvedOptions().timeZone when TZ is also unset (D-06)"
- "isValidIanaTimezone accepts real IANA zones including 'UTC', 'Etc/UTC', and 'America/Chicago', and rejects garbage like 'Not/AZone'"
artifacts:
- path: "apps/api/src/lib/householdTimezone.ts"
provides: "Shared stored-timezone accessor (D-05 single accessor) + IANA validator"
exports: ["getHouseholdTimezone", "isValidIanaTimezone"]
- path: "apps/api/tests/lib/householdTimezone.test.ts"
provides: "RED→GREEN unit coverage for accessor fallback chain + validator"
key_links:
- from: "apps/api/src/lib/householdTimezone.ts"
to: "app_config row key='household_timezone'"
via: "drizzle select on appConfig"
pattern: "appConfig.*household_timezone"
---
<objective>
Create the single shared accessor and IANA validator that every other Phase 18 task
depends on. `getHouseholdTimezone(db)` reads the `household_timezone` key from `app_config`
and applies the D-06 fallback chain; `isValidIanaTimezone(tz)` validates an IANA string via
`Intl.DateTimeFormat` try/catch.
Purpose: D-05 mandates ONE accessor so `reminderScheduler.ts` and `outboxWorker.ts` cannot
drift; D-06 mandates the exact `process.env.TZ ?? Intl…` fallback so existing all-day tests
stay green with no modification. This plan is the foundation both later waves consume.
Output: `apps/api/src/lib/householdTimezone.ts` exporting `getHouseholdTimezone` + `isValidIanaTimezone`, fully unit-tested.
</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
</context>
<tasks>
<task type="tdd" tdd="true">
<name>Task 1: RED — failing unit tests for getHouseholdTimezone fallback chain + isValidIanaTimezone</name>
<files>apps/api/tests/lib/householdTimezone.test.ts</files>
<read_first>
- apps/api/tests/lib/requireAdmin.test.ts (vitest structure + how a mock Drizzle db is built for a lib helper)
- apps/api/tests/broker/reminderScheduler.test.ts (how existing tests pin process.env.TZ in beforeEach/afterEach — mirror the env save/restore)
- apps/api/src/db/schema.ts §282 (appConfig key/value/updatedAt shape — the row this accessor reads)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-RESEARCH.md (Pitfall 2 = why 'UTC' must pass; Validation Architecture → Test Map)
</read_first>
<behavior>
- getHouseholdTimezone: when the mocked db returns a row { value: 'America/Chicago' } for key 'household_timezone' → resolves to 'America/Chicago'.
- getHouseholdTimezone: when the mocked db returns no row (empty array) AND process.env.TZ='America/New_York' → resolves to 'America/New_York'.
- getHouseholdTimezone: when no row AND process.env.TZ is deleted → resolves to Intl.DateTimeFormat().resolvedOptions().timeZone (assert it equals that exact runtime value, not a hardcoded string).
- getHouseholdTimezone: when the row exists but value is null → falls through to the process.env.TZ branch (null is treated as unset).
- isValidIanaTimezone: returns true for 'UTC', 'Etc/UTC', 'America/Chicago', 'Europe/London'.
- isValidIanaTimezone: returns false for 'Not/AZone', '', 'Mars/Phobos'.
</behavior>
<action>
Create the test file mocking the Drizzle query chain the same way requireAdmin.test.ts mocks
`db` (select→from→where→limit returning a Promise of an array). Save and restore
`process.env.TZ` around each test (capture original in beforeEach, restore in afterEach) so the
fallback-branch tests do not leak env state. Import the not-yet-existing symbols
`getHouseholdTimezone` and `isValidIanaTimezone` from `../../src/lib/householdTimezone.js`.
Run the suite and confirm it FAILS because the module does not exist yet (import/resolution
error is the expected RED). Commit as `test(18-01): add failing tests for household timezone accessor + IANA validator`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts 2>&1 | grep -qi "fail\|error\|cannot find" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- File `apps/api/tests/lib/householdTimezone.test.ts` exists with the six behavior cases above.
- The test run fails (RED) prior to any implementation — failure is module-not-found / missing-export, not a syntax error in the test.
- A `test(18-01): ...` commit precedes the implementation commit.
</acceptance_criteria>
<done>RED gate met: failing test committed, fails for the right reason (no implementation).</done>
</task>
<task type="tdd" tdd="true">
<name>Task 2: GREEN — implement getHouseholdTimezone + isValidIanaTimezone</name>
<files>apps/api/src/lib/householdTimezone.ts</files>
<read_first>
- apps/api/src/lib/requireAdmin.ts (analog: single-purpose lib helper, drizzle select→from→where→limit(1), typed return — copy import + query style)
- apps/api/src/db/schema.ts §282 (appConfig — import { appConfig } from '../db/schema.js')
- apps/api/src/broker/reminderScheduler.ts §247 (the EXACT fallback expression `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` to reproduce verbatim per D-06)
- .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-PATTERNS.md (householdTimezone.ts section — full target shape)
</read_first>
<action>
Create `apps/api/src/lib/householdTimezone.ts`. Export `async getHouseholdTimezone(db: MySql2Database<typeof schema>): Promise<string>` that selects `{ value: appConfig.value }` from `appConfig`
where `eq(appConfig.key, 'household_timezone')` `.limit(1)`, destructures `const [row]`, and returns
`row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone` — the D-06 fallback
reproduced verbatim from the bare lookups. Use the literal key string `'household_timezone'` (D-01).
Export `isValidIanaTimezone(tz: string): boolean` that returns true if `Intl.DateTimeFormat(undefined, { timeZone: tz })` does not throw, false on catch — do NOT use `Intl.supportedValuesOf` (it omits 'UTC', RESEARCH Pitfall 2).
Import types via `import type { MySql2Database } from 'drizzle-orm/mysql2'` and `import type * as schema from '../db/schema.js'`.
Run the test; iterate until GREEN. Commit as `feat(18-01): implement household timezone accessor + IANA validator`.
</action>
<verify>
<automated>pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts</automated>
</verify>
<acceptance_criteria>
- `apps/api/src/lib/householdTimezone.ts` exports `getHouseholdTimezone` and `isValidIanaTimezone`.
- Source contains the literal key `'household_timezone'` and the verbatim fallback `process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`.
- Source does NOT reference `Intl.supportedValuesOf` (assert: `grep -L "supportedValuesOf" apps/api/src/lib/householdTimezone.ts`).
- All six behavior cases pass (GREEN).
- `pnpm --filter @familysync/api exec tsc --noEmit` passes for the new file.
- A `feat(18-01): ...` commit follows the RED commit.
</acceptance_criteria>
<done>GREEN gate met: implementation passes all unit tests; typecheck clean.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| scheduler/outbox → DB | trusted server process reads a stored config value; the value was written through the validated admin/seed path (Plan 02) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-18-01 | Tampering | stored household_timezone consumed by computeAlertInstantUtc | mitigate | This plan only READS the value; all writes go through Plan 02's Zod `isValidIanaTimezone` refine, so a garbage zone can never be stored to break the scheduler. The accessor additionally never passes an unvalidated value (write path is the gate). |
| T-18-02 | Denial of Service | getHouseholdTimezone DB read per scheduler tick | accept | Single PK lookup on a 60s interval; negligible. Read-per-run chosen for correctness (changes propagate within one tick) — RESEARCH A1. |
| T-18-SC | Tampering | npm/pip/cargo installs | mitigate | No new packages installed this plan (RESEARCH Package Legitimacy Audit: zero installs). No legitimacy checkpoint needed. |
</threat_model>
<verification>
- `pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts` green.
- `pnpm --filter @familysync/api test -- --run` still green (no regression in existing suites).
</verification>
<success_criteria>
- `getHouseholdTimezone` + `isValidIanaTimezone` exist, exported, unit-tested.
- D-06 fallback verbatim; D-05 single accessor established for both broker sites to import.
- RED then GREEN commits present.
</success_criteria>
<output>
Create `.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-01-SUMMARY.md` when done.
</output>