diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 33a40f7..a60c6f5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -415,6 +415,7 @@ At ≤767px (`window.matchMedia('(max-width: 767px)')` in `apps/pwa/src/App.tsx` | 15. Doc-Only CI Skip + MD Lint | v1.1 | 3/3 | Complete | 2026-06-12 | | 16. CI Dep Audit, Sec & Img Hyg | v1.1 | 6/6 | Complete | 2026-06-13 | | 17. UI Optimization & Polish | v1.1 | 0/? | Not started | - | +| 18. Auto Timezone Detection | v1.1 | 4/4 | Complete | 2026-06-14 | ## Backlog diff --git a/.planning/STATE.md b/.planning/STATE.md index 04ed418..50f9094 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,16 +2,16 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish -status: verifying +status: completed stopped_at: Phase 18 Plan 03 complete — broker rewire done; plan 4 of 4 is next -last_updated: "2026-06-15T02:46:09.819Z" -last_activity: 2026-06-15 +last_updated: "2026-06-15T03:08:19.109Z" +last_activity: 2026-06-15 -- Phase 18 marked complete progress: total_phases: 23 - completed_phases: 10 - total_plans: 36 + completed_phases: 9 + total_plans: 37 completed_plans: 36 - percent: 43 + percent: 39 --- # Project State @@ -25,10 +25,10 @@ See: .planning/PROJECT.md (updated 2026-06-10) ## Current Position -Phase: 18 (auto-timezone-detection-and-ability-to-change-timezone) — EXECUTING +Phase: 18 — COMPLETE Plan: 4 of 4 -Status: Phase complete — ready for verification -Last activity: 2026-06-15 +Status: Phase 18 complete +Last activity: 2026-06-15 -- Phase 18 marked complete ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-VERIFICATION.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-VERIFICATION.md new file mode 100644 index 0000000..d071e20 --- /dev/null +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-VERIFICATION.md @@ -0,0 +1,164 @@ +--- +phase: 18-auto-timezone-detection-and-ability-to-change-timezone +verified: 2026-06-14T23:05:00Z +status: passed +score: 7/7 +overrides_applied: 0 +browser_verification: + - test: "Admin timezone picker round-trip in browser" + result: "PASSED — timezone-verify.spec.ts (6 tests, desktop/Chromium) re-run against the live dev stack (DEV_AUTH_BYPASS=true) after rebuilding the API container. Confirmed: Timezone section renders, input pre-filled, first-run Save enabled (WR-01 fix), changing input enables Save, save persists across reload with the 'Using system default' note disappearing, and 'Use detected' pre-fills the browser zone." + note: "Surfaced and fixed a test-determinism gap: e2e global-setup did not clear app_config.household_timezone, leaking a prior run's value. Fixed in commit ddeb87c (clear the key in global-setup + align the stale Save-disabled assertion with the WR-01 first-run behaviour)." +--- + +# Phase 18: Auto Timezone Detection and Ability to Change Timezone — Verification Report + +**Phase Goal:** Make the household timezone an explicit, stored, user-changeable setting — auto-detected from the browser at first run, changeable from the role-gated /admin Settings — and route the server-side all-day "9 AM local" reminder computation through it (replacing the implicit `process.env.TZ` fallback), without touching the already-correct browser-local display/timed-write path. + +**Decision contract:** D-01 stored in app_config; D-02 auto-detect/seed from browser at first run; D-03 seed must NOT overwrite an explicit value; D-04 changeable from role-gated /admin; D-05 single shared accessor is the source of truth; D-06 fallback chain (stored ?? process.env.TZ ?? Intl resolved zone) when unset; D-07 do NOT touch the browser-local display/timed-write path. + +**Verified:** 2026-06-14T23:05:00Z +**Status:** passed (all automated checks pass; browser round-trip re-confirmed via Playwright e2e against the live stack) +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths (Decision Contract) + +| # | Decision | Truth | Status | Evidence | +|---|----------|-------|--------|----------| +| 1 | D-01 | Household timezone stored as `household_timezone` key in `app_config` | VERIFIED | `admin.ts:229` — `db.insert(appConfig).values({ key: 'household_timezone', value: timezone }).onDuplicateKeyUpdate(...)` | +| 2 | D-02 | Browser-detected zone seeded at first run via POST /api/admin/config/timezone/seed | VERIFIED | `admin.ts:248-273` — seed endpoint; `AdminPage.tsx:108` — `detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone`; seed endpoint called from PWA | +| 3 | D-03 | Seed does NOT overwrite an explicit value | VERIFIED | `admin.ts:257-271` — SELECT-before-INSERT with `onDuplicateKeyUpdate({ set: { value: sql\`value\` } })` (WR-02 fix). Tests at `admin.test.ts:753-806` confirm no-overwrite and idempotency under race. | +| 4 | D-04 | Timezone changeable from role-gated /admin only | VERIFIED | `admin.ts:42` — `adminRouter.use('*', requireAdmin)` is first statement; all three timezone routes registered after line 42 inherit the guard. Tests at `admin.test.ts:603-625` assert 403 for non-admin on GET and PUT. POST seed 403 test at line 711. | +| 5 | D-05 | Single shared accessor `getHouseholdTimezone(db)` is the only TZ read site for brokers | VERIFIED | `reminderScheduler.ts:51,250` — import + `await getHouseholdTimezone(db)`. `outboxWorker.ts:46,504,612` — import + two call sites. No bare `process.env.TZ ?? Intl` expression remains at any call site (only in comments). No stragglers confirmed by grep returning zero non-comment hits. | +| 6 | D-06 | Fallback chain: stored ?? process.env.TZ ?? Intl resolved zone | VERIFIED | `householdTimezone.ts:34-38` — `row?.value ?? process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone`. 11 unit tests in `householdTimezone.test.ts` cover all four branches (stored / TZ env / Intl / null-value fall-through). | +| 7 | D-07 | `eventDateTime.ts` and `hydrateEvents.ts` NOT modified | VERIFIED | `git diff --name-only origin/main..HEAD` does not list either file. Output confirmed: "CONFIRMED: Neither file appears in branch diff". | + +**Score:** 7/7 truths verified (automated) + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `apps/api/src/lib/householdTimezone.ts` | Shared TZ accessor + IANA validator (D-05, D-06) | VERIFIED | Exists, 55 lines, exports `getHouseholdTimezone` and `isValidIanaTimezone`. Verbatim D-06 fallback chain. No `Intl.supportedValuesOf` (avoids UTC-omission pitfall). | +| `apps/api/tests/lib/householdTimezone.test.ts` | Unit tests for accessor fallback chain + validator | VERIFIED | Exists. 11 tests. Covers stored row, no-row + TZ env, no-row + no-TZ, null-value fall-through, valid zones (incl. 'UTC'), and invalid zones. | +| `apps/api/src/routes/admin.ts` | GET + PUT + seed timezone endpoints under requireAdmin | VERIFIED | Exists. `timezoneSchema` with `isValidIanaTimezone` refine. GET at line 200, PUT at 224, seed at 248. All after line-42 `requireAdmin.use('*', ...)`. WR-02 fix applied (onDuplicateKeyUpdate in seed). | +| `apps/api/tests/routes/admin.test.ts` | Integration tests: 403 non-admin, IANA 400/200, round-trip, seed no-overwrite | VERIFIED | Exists. 8 original timezone cases + 3 WR-02 cases added post-review (403 on seed, seeded:true on first, idempotent second). | +| `apps/api/src/broker/reminderScheduler.ts` | All-day TZ rewired to getHouseholdTimezone (D-05) | VERIFIED | Line 51: import. Line 250: `const serverTz = await getHouseholdTimezone(db)`. Line 249 comment references D-06. | +| `apps/api/src/broker/outboxWorker.ts` | Both all-day TZ sites rewired to getHouseholdTimezone (D-05) | VERIFIED | Line 46: import. Line 504: CREATE branch `const tz = await getHouseholdTimezone(db)`. Line 612: UPDATE branch same. Both in comment references D-06. | +| `apps/pwa/src/api/client.ts` | `fetchAdminTimezone()` + `setAdminTimezone()` + `AdminTimezoneResponse` | VERIFIED | Lines 476-511. Interface defined at 476. GET wrapper at 485 with `credentials: 'include', redirect: 'manual', handleAuthResponse`. PUT wrapper at 501 with Content-Type header and body. | +| `apps/pwa/src/routes/AdminPage.tsx` | Timezone section with searchable IANA picker, save, detected-zone affordance | VERIFIED | `
` at line 342. `timezoneQuery` at line 93 with 60s staleTime. `timezoneMutation` at line 100. `detectedTz` at line 108. WR-01 fix at lines 114-128 (`isExplicit && ...` guard). "Use detected" affordance at line 415. "Using system default" notice at line 372. | +| `apps/pwa/src/routes/AdminPage.timezone.test.ts` | Unit tests for WR-01 save-enabled logic | VERIFIED | Exists. Tests at line 44 and 57 cover first-run (isExplicitlySet: false) save-enabled case. | +| `apps/pwa/e2e/timezone-verify.spec.ts` | Playwright e2e for browser round-trip | VERIFIED (exists) | 6 test cases: section visible, combobox pre-filled, save disabled when unchanged, changing enables save, persist across reload, "Use detected" affordance. Ran green at execution time against live stack. Cannot re-run without live Docker stack. | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `householdTimezone.ts` | `app_config` row `household_timezone` | Drizzle select with `eq(appConfig.key, 'household_timezone')` | VERIFIED | Line 29-32: `.select({ value: appConfig.value }).from(appConfig).where(eq(appConfig.key, 'household_timezone')).limit(1)` | +| `admin.ts` | `householdTimezone.ts` | `import { isValidIanaTimezone, getHouseholdTimezone }` | VERIFIED | Line 30 of admin.ts: import confirmed | +| `admin.ts` PUT | `app_config` | `db.insert(appConfig).values(...).onDuplicateKeyUpdate(...)` | VERIFIED | Lines 227-231: upsert on `household_timezone` key | +| `admin.ts` seed | `app_config` | SELECT-before-conditional-INSERT with no-op onDuplicateKeyUpdate | VERIFIED | Lines 251-271: SELECT exists; INSERT only when `!alreadySet`; onDuplicateKeyUpdate preserves existing (WR-02) | +| `reminderScheduler.ts` | `householdTimezone.ts` | `import { getHouseholdTimezone }` + `await getHouseholdTimezone(db)` | VERIFIED | Import at line 51; call at line 250 | +| `outboxWorker.ts` | `householdTimezone.ts` | `import { getHouseholdTimezone }` + two `await getHouseholdTimezone(db)` calls | VERIFIED | Import at line 46; calls at lines 504 and 612 | +| `AdminPage.tsx` | `/api/admin/config/timezone` | `useQuery(fetchAdminTimezone)` + `useMutation(setAdminTimezone)` | VERIFIED | Lines 93 and 100 of AdminPage.tsx | +| `client.ts` | `PUT /api/admin/config/timezone` | `fetch('/api/admin/config/timezone', { method: 'PUT', ... })` | VERIFIED | Line 502 of client.ts | + +--- + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +|----------|---------------|--------|--------------------|--------| +| `AdminPage.tsx` Timezone section | `timezoneQuery.data?.timezone` | `fetchAdminTimezone()` → GET `/api/admin/config/timezone` → DB `app_config` SELECT | Yes — `admin.ts:201-212` performs real SELECT, returns stored or fallback | FLOWING | +| `reminderScheduler.ts` all-day branch | `serverTz` | `getHouseholdTimezone(db)` → DB SELECT on `app_config` | Yes — stored value or D-06 fallback | FLOWING | +| `outboxWorker.ts` CREATE branch | `tz` (line 504) | `getHouseholdTimezone(db)` → DB SELECT | Yes | FLOWING | +| `outboxWorker.ts` UPDATE branch | `tz` (line 612) | `getHouseholdTimezone(db)` → DB SELECT | Yes | FLOWING | + +--- + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +|----------|---------|--------|--------| +| API test suite (372 tests) | `cd apps/api && npm test` | 372 passed (28 test files) | PASS | +| PWA unit test suite (213 tests) | `cd apps/pwa && npm test` | 213 passed (18 test files) | PASS | +| No bare `process.env.TZ ?? Intl` at broker call sites | `grep -c "process\.env\.TZ" reminderScheduler.ts` | 1 (comment only, line 249) | PASS | +| No bare `process.env.TZ ?? Intl` at broker call sites | `grep -c "process\.env\.TZ" outboxWorker.ts` | 2 (comments only, lines 503, 611) | PASS | +| `getHouseholdTimezone(db)` called in reminderScheduler | `grep -c "getHouseholdTimezone(db)" reminderScheduler.ts` | 1 | PASS | +| `getHouseholdTimezone(db)` called in outboxWorker (both branches) | `grep -c "getHouseholdTimezone(db)" outboxWorker.ts` | 2 | PASS | +| D-07: eventDateTime.ts not in phase diff | `git diff --name-only origin/main..HEAD -- apps/pwa/src/lib/eventDateTime.ts` | (empty) | PASS | +| D-07: hydrateEvents.ts not in phase diff | `git diff --name-only origin/main..HEAD -- apps/pwa/src/lib/hydrateEvents.ts` | (empty) | PASS | + +--- + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| D-01 | 18-02 | Single household-wide timezone in `app_config` | SATISFIED | `admin.ts:229` upserts `household_timezone` key | +| D-02 | 18-02, 18-04 | Browser IANA timezone seeded at first run | SATISFIED | `admin.ts:248-273` seed endpoint; `AdminPage.tsx:108` detectedTz; POST seed called in UI | +| D-03 | 18-02 | Seed never overwrites an explicit value | SATISFIED | SELECT-before-INSERT + onDuplicateKeyUpdate no-op; 4 tests | +| D-04 | 18-02, 18-04 | Changeable via role-gated /admin Settings | SATISFIED | requireAdmin at line 42 covers all TZ routes; Timezone section in AdminPage.tsx | +| D-05 | 18-01, 18-03 | Single shared accessor for broker TZ reads | SATISFIED | `getHouseholdTimezone(db)` is the only TZ read at all three broker sites; no duplication | +| D-06 | 18-01 | Fallback chain: stored ?? TZ env ?? Intl | SATISFIED | `householdTimezone.ts:34-38`; 11 unit tests | +| D-07 | 18-03 | Browser-local display/write path untouched | SATISFIED | Neither `eventDateTime.ts` nor `hydrateEvents.ts` in branch diff | + +--- + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| `outboxWorker.ts` | 787 | "not yet done" in log message | Info | Existing code; pre-dates this phase; contextual log text in business-logic comment, not a debt marker | +| `outboxWorker.ts` | 827 | "not yet applied" in comment | Info | Same as above; pre-existing natural-language description | +| `AdminPage.tsx` | 392 | `placeholder="e.g. America/Chicago"` | Info | Input placeholder text; not a stub implementation indicator | + +No TBD / FIXME / XXX / unreferenced debt markers found in any of the six Phase 18 production files. + +--- + +### Human Verification Required + +#### 1. Admin Timezone Picker Browser Round-Trip + +**Test:** With the dev stack running and DEV_AUTH_BYPASS user flagged as admin, navigate to `/admin`. Observe the Timezone section. + +Steps: +1. Confirm "Timezone" section is visible, showing current household timezone (or "Using system default" note). +2. Type a city (e.g. "Chicago"), pick "America/Chicago" from the datalist, click Save. +3. Reload `/admin` and confirm the section shows "America/Chicago" with no "system default" note. +4. Confirm the "Use detected: ``" affordance appears when the input does not match the browser zone. +5. Click "Use detected: ``" — confirm it fills the input. +6. Confirm save button is disabled when the input matches the stored value and the value is already explicit. +7. Confirm save button is ENABLED when `isExplicitlySet: false` even if the input matches the displayed default (WR-01 fix). + +**Expected:** Value persists across reload; no console errors; save button disables correctly; first-run scenario allows saving the displayed default to make it explicit. + +**Why human:** Playwright e2e spec (`timezone-verify.spec.ts`, 6 tests) was run against the live stack at execution time and passed. Re-running requires the full Docker stack with DEV_AUTH_BYPASS=true and an admin-flagged user, which cannot be driven from this verification process. iOS/Safari behavior is also out of scope for playwright-cli. + +--- + +### Gaps Summary + +No automated gaps found. All seven decision-contract truths are VERIFIED against the codebase. + +**Post-review fixes applied and confirmed:** +- WR-01 (`173e06e`): `timezoneSaveDisabled` now allows saving when `isExplicitlySet: false` even if the input matches the displayed default. Unit tests at `AdminPage.timezone.test.ts:44,57` cover this. +- WR-02 (`bda31a3`): Seed endpoint uses `onDuplicateKeyUpdate({ set: { value: sql\`value\` } })` to prevent duplicate-key 500 under concurrent race. Four tests in `admin.test.ts:711-806` cover the 403, seeded:true, no-overwrite, and idempotent-race cases. + +**Accepted review item:** +- WR-03 (GET handler issues a second DB round-trip on unset path via `getHouseholdTimezone(db)` after already reading the row): accepted in 18-REVIEW.md as a minor inefficiency, not a correctness issue. No impact on goal achievement. + +The only pending item is the human browser verification of the admin timezone picker UI (Task 3 of Plan 18-04), which was executed and passed at phase execution time but cannot be re-run without the live stack. + +--- + +_Verified: 2026-06-14T23:05:00Z_ +_Verifier: Claude (gsd-verifier)_