diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW-FIX.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW-FIX.md new file mode 100644 index 0000000..f75c6c1 --- /dev/null +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW-FIX.md @@ -0,0 +1,92 @@ +--- +phase: 18-auto-timezone-detection-and-ability-to-change-timezone +fixed_at: 2026-06-15T07:43:30Z +review_path: .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md +iteration: 1 +findings_in_scope: 5 +fixed: 5 +skipped: 0 +status: all_fixed +--- + +# Phase 18: Code Review Fix Report + +**Fixed at:** 2026-06-15T07:43:30Z +**Source review:** .planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md +**Iteration:** 1 + +**Summary:** +- Findings in scope: 5 (2 Warning + 3 Info; fix_scope = all) +- Fixed: 5 +- Skipped: 0 + +All in-scope findings were fixed. The full API test suite (375 tests across 28 +files) and `tsc --noEmit` pass cleanly. No PWA files were touched, so PWA tests +were not run. + +## Fixed Issues + +### WR-01: Empty-but-set `process.env.TZ` defeats the D-06 fallback and yields an invalid zone + +**Files modified:** `apps/api/src/lib/householdTimezone.ts`, `apps/api/tests/lib/householdTimezone.test.ts` +**Commit:** d168da7 +**Applied fix:** Extracted the D-06 fallback into a new `resolveHouseholdTimezone(storedValue)` +helper that `.trim()`s candidate values and treats empty/whitespace-only values +(both the stored value and `process.env.TZ`) as absent so they fall through to the +`Intl` resolved zone, instead of relying on `??` which only short-circuits on +null/undefined. `getHouseholdTimezone` now delegates to it. Added RED→GREEN unit +tests for `TZ=''` and `TZ=' '` proving fall-through to the Intl zone. Updated the +doc comment to reflect the now-enforced "non-empty" guarantee. + +### WR-02: Seed `seeded` flag can misreport under a real concurrent race + +**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts` +**Commit:** 93217b5 +**Applied fix:** Replaced the pre-flight `SELECT` + conditional +`onDuplicateKeyUpdate` with a single `INSERT IGNORE` and derive `seeded` from the +result's `affectedRows`. **Note (deviation from the review's literal suggestion):** +the review proposed `seeded: insertResult.affectedRows === 1` against the existing +`onDuplicateKeyUpdate(value=value)`. I empirically probed this MariaDB and found +`onDuplicateKeyUpdate(value=value)` returns `affectedRows: 1` for BOTH a fresh insert +and a no-op duplicate, so it cannot distinguish them. `INSERT IGNORE` reliably returns +`affectedRows: 1` on insert and `0` when the row already exists (ignored, value +preserved — D-03), which is what makes the derived flag accurate under a concurrent +race. The `timezone` is interpolated via drizzle's parameterized `sql` template (bound +param, not string concatenation) and is already IANA-validated by `timezoneSchema`. +Corrected the overstated in-code comment. Added a test asserting `seeded:false` for a +row pre-inserted directly (bypassing the endpoint), which only an INSERT-derived flag +can satisfy. + +### IN-01: `getHouseholdTimezone` re-runs the same `app_config` SELECT the GET handler just issued + +**Files modified:** `apps/api/src/routes/admin.ts` +**Commit:** 692fe2a +**Applied fix:** The `GET /config/timezone` handler now reuses the row it already +SELECTed by calling `resolveHouseholdTimezone(row?.value ?? null)` instead of +`getHouseholdTimezone(db)`, removing the redundant second `app_config` round-trip on +the unset path. Behavior unchanged. + +### IN-02: D-06 fallback policy is duplicated between the accessor and the GET handler + +**Files modified:** `apps/api/src/lib/householdTimezone.ts`, `apps/api/src/routes/admin.ts` +**Commit:** d168da7 (accessor), 692fe2a (handler) +**Applied fix:** Centralized the fallback policy in the new `resolveHouseholdTimezone` +helper (single source of truth, D-05 intent). The GET handler derives +`isExplicitlySet` from row presence and routes the value through the same helper, so +the WR-01 empty-`TZ` guard lives in exactly one place and the two sites cannot drift. + +### IN-03: `outboxWorker` may read the stored timezone twice within one drain cycle + +**Files modified:** `apps/api/src/broker/outboxWorker.ts` +**Commit:** 1fb431e +**Applied fix:** Added a lazy per-drain-cycle `TimezoneResolver` (mirroring the +existing `clientCache` thread-through, IN-01) created in `runOutboxDrain` and passed +into `dispatchRow`. The UPDATE and CREATE all-day branches now share a single +`app_config` read. The read stays lazy — cycles with no all-day work never touch the +DB. Behavior unchanged; all 39 outboxWorker tests pass. + +--- + +_Fixed: 2026-06-15T07:43:30Z_ +_Fixer: Claude (gsd-code-fixer)_ +_Iteration: 1_ diff --git a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md index 07e0e16..da2e6f9 100644 --- a/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md +++ b/.planning/phases/18-auto-timezone-detection-and-ability-to-change-timezone/18-REVIEW.md @@ -1,8 +1,8 @@ --- phase: 18-auto-timezone-detection-and-ability-to-change-timezone -reviewed: 2026-06-15T02:49:55Z +reviewed: 2026-06-15T04:30:00Z depth: standard -files_reviewed: 6 +files_reviewed: 8 files_reviewed_list: - apps/api/src/lib/householdTimezone.ts - apps/api/src/routes/admin.ts @@ -10,112 +10,91 @@ files_reviewed_list: - apps/api/src/broker/outboxWorker.ts - apps/pwa/src/api/client.ts - apps/pwa/src/routes/AdminPage.tsx + - apps/api/tests/lib/householdTimezone.test.ts + - apps/api/tests/routes/admin.test.ts findings: critical: 0 - warning: 3 - info: 4 - total: 7 -status: wr-01-resolved wr-02-resolved wr-03-accepted + warning: 0 + info: 0 + total: 0 +status: clean --- # Phase 18: Code Review Report -**Reviewed:** 2026-06-15T02:49:55Z +**Reviewed:** 2026-06-15T04:30:00Z **Depth:** standard -**Files Reviewed:** 6 (production) + 5 test files (coverage review) -**Status:** issues-found +**Files Reviewed:** 8 +**Status:** clean ## Summary -Phase 18 adds a stored household timezone with auto-detection, an admin picker, and rewires the all-day "9 AM local" reminder math through a single shared accessor. The core security and correctness contract holds up well under adversarial review: +Re-review (iteration 2 of the --auto fix loop) of Phase 18 timezone changes after fixes for +WR-01 (empty/whitespace `process.env.TZ` fallback guard), WR-02 (`seeded` derived from +`INSERT IGNORE` affectedRows), and IN-01/02/03 (reuse fetched row on GET unset path; centralized +D-06 fallback; per-drain-cycle timezone memoization). All previously-raised findings are resolved. +No new Critical or Warning defects were introduced. -- **Access control (D-04) is sound.** All three new endpoints (`GET`/`PUT /config/timezone`, `POST /config/timezone/seed`) are registered on `adminRouter` *after* `adminRouter.use('*', requireAdmin)` (admin.ts:42), so they inherit the guard. Tests assert 403 for non-admins on GET and PUT (admin.test.ts:603-625). -- **Injection safety is solid.** User-supplied timezone strings are validated by `isValidIanaTimezone` (Zod `.refine`) *and* Drizzle parameterizes the INSERT/upsert — no string interpolation reaches SQL. No path traversal or command surface is touched. -- **D-03 no-overwrite seed semantics are correct** (SELECT-then-conditional-INSERT, admin.test.ts:730-754). -- **D-05/D-06 accessor + fallback chain is correct** and well-tested (householdTimezone.test.ts), including the null-value fall-through. -- **D-07 boundary respected** — the browser-local display/serialization path is untouched. +## Narrative Findings (AI reviewer) -No Critical findings. Three Warnings: a genuine logic defect that prevents an admin from ever saving the displayed system-default value (contradicting the on-screen instruction), an unhandled race/duplicate-key path on the seed endpoint, and a redundant DB round-trip on the GET handler. Four Info items. +No Critical, Warning, or actionable Info findings remain. Verification notes below. -## Warnings +### Verification of applied fixes -### WR-01: Admin cannot save the displayed system-default timezone to make it explicit — RESOLVED +- **WR-01 — empty/whitespace TZ fallthrough (`apps/api/src/lib/householdTimezone.ts:49-61`).** + Correct. `resolveHouseholdTimezone` trims the stored value first; a set-but-blank stored value + falls through, then `process.env.TZ?.trim()` rejects `''`/`' '` and falls through to the Intl + zone. The normal stored-value path (`stored` truthy after trim) and the unset path are both + preserved. D-05 (single accessor — both `reminderScheduler.ts:250` and `outboxWorker.ts:391` + route through `getHouseholdTimezone`) and D-06 (stored → env.TZ → Intl chain) still hold. New + unit tests pin both empty and whitespace cases (`householdTimezone.test.ts:101-117`). -**File:** `apps/pwa/src/routes/AdminPage.tsx:111-118` (with 362-372) -**Status:** Fixed in commit `173e06e` — `fix(18): enable first-run timezone save when not explicitly set (WR-01)` -**Issue:** When no row is stored, `GET /config/timezone` returns `isExplicitlySet: false` and `timezone` = the D-06 fallback (e.g. `UTC` inside Docker, or the detected zone). The UI pre-fills the input with that fallback (`effectiveTimezoneInput = timezoneInput ?? storedTimezone`) and simultaneously shows the notice **"Using system default — save a timezone to make it explicit."** But `timezoneSaveDisabled` includes `effectiveTimezoneInput === storedTimezone` (line 118). Since the input already equals `storedTimezone` (the fallback), **Save is disabled** — the admin literally cannot perform the action the notice instructs. They must first change the value to something else, then back, or pick a different zone, to enable Save. This defeats the explicit-set affordance for the common first-run case where the detected/fallback zone is already correct. -**Fix applied:** -```tsx -const isExplicit = timezoneQuery.data?.isExplicitlySet ?? false; -const timezoneSaveDisabled = - timezoneMutation.isPending || - effectiveTimezoneInput === '' || - (isExplicit && effectiveTimezoneInput === storedTimezone); -``` -Unit tests added in `apps/pwa/src/routes/AdminPage.timezone.test.ts` (7 tests covering first-run enabled and already-explicit disabled cases). +- **WR-02 — `seeded` derived from affectedRows (`apps/api/src/routes/admin.ts:251-269`).** + Correct. The endpoint runs a single `INSERT IGNORE` and derives `seeded` from + `affectedRows === 1`. On MariaDB an ignored duplicate yields `affectedRows === 0`, so the flag + is accurate even under a genuine concurrent race — only the racer whose row actually wrote gets + `seeded:true`. D-03 no-overwrite is preserved (duplicate is silently ignored, value untouched). + The IANA value is validated by `timezoneSchema.refine(isValidIanaTimezone)` before the handler + runs and is bound as a parameterized value via drizzle's `sql` template (not string- + concatenated); column identifiers use `sql.identifier` — no injection. `seeded:false` accuracy + is pinned by the pre-inserted-row test (`admin.test.ts:821-846`) and the idempotent-race test + (`:783-815`). -### WR-02: Seed endpoint has an unhandled duplicate-key path (race + no try/catch) — RESOLVED +- **IN-01/02 — GET unset path reuses the fetched row (`apps/api/src/routes/admin.ts:200-213`).** + Correct. The GET handler SELECTs once, computes `isExplicitlySet` from `row?.value != null`, and + passes the same `row?.value ?? null` to the centralized `resolveHouseholdTimezone`. No second + app_config round-trip; the D-06 fallback policy lives in exactly one function. Semantics + unchanged: unset → fallback timezone + `isExplicitlySet:false`; set → stored value + `true` + (`admin.test.ts:631-663`). -**File:** `apps/api/src/routes/admin.ts:248-265` -**Status:** Fixed in commit `bda31a3` — `fix(18): make timezone seed idempotent under concurrent race (WR-02)` -**Issue:** `POST /config/timezone/seed` does a non-transactional SELECT, then a bare `INSERT` (no `onDuplicateKeyUpdate`) when `!alreadySet`. Two concurrent seeds, or a seed racing a `PUT`, can both observe "unset" and both attempt the INSERT; the second hits the `app_config.key` primary-key constraint and throws. Because there is no `try/catch`, the rejection propagates as an unhandled 500 rather than the documented `200 { ok, seeded }`. The first-run wizard calling this on initial load makes the seed-vs-PUT overlap plausible. (For a two-person household the probability is low, hence Warning not Blocker — but the failure mode is a 500 with a stack, not graceful no-op.) -**Fix applied:** Added `onDuplicateKeyUpdate({ set: { value: sql\`value\` } })` to the INSERT so the PK constraint can never be violated. The `seeded` flag still reflects the pre-flight SELECT. Three new tests added in `apps/api/tests/routes/admin.test.ts`: 403 access control on POST seed, `seeded:true` on first seed, and sequential idempotent second seed returns `seeded:false` without throwing. +- **IN-03 — per-drain-cycle timezone memoization + (`apps/api/src/broker/outboxWorker.ts:385-395, 774`).** Correct. `makeTimezoneResolver` lazily + caches the `getHouseholdTimezone(db)` promise so multiple all-day rows in one drain cycle share a + single app_config read; cycles with no all-day work never touch the DB. The resolver is created + per-cycle and discarded at cycle end, so a transient DB failure caching for one cycle is retried + fresh next cycle, and a rejected resolve surfaces through the existing per-row catch as correct + pending/transient behavior. No double-read regression. Both create (`:634`) and update (`:528`) + all-day branches consume the shared resolver. -### WR-03: GET /config/timezone issues a redundant second DB query on the unset path — ACCEPTED (not fixing) +### Other checks (no regressions) -**File:** `apps/api/src/routes/admin.ts:200-213` -**Issue:** The handler selects `household_timezone` (lines 201-205), then when `row?.value == null` calls `await getHouseholdTimezone(db)` (line 210) — which runs the *same* `SELECT` again before applying the fallback chain. Two round-trips for the common first-run case. Not a correctness bug, but it duplicates the query the accessor already performs and couples the handler to the accessor's internals. -**Fix:** Compute the fallback inline from the already-fetched `row`, or have `getHouseholdTimezone` accept an optional pre-fetched value. Minimal inline version: -```ts -const isExplicitlySet = row?.value != null; -const timezone = isExplicitlySet - ? (row.value as string) - : (process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone); -``` -(Keeps a single SELECT; mirrors the accessor's D-06 chain — extract a shared `resolveFallbackTz()` if you want to avoid drift.) +- **Access control.** `adminRouter.use('*', requireAdmin)` remains the first router statement; all + three timezone routes (GET/PUT/POST-seed) sit behind it. 403 coverage exists for GET, PUT, and + seed (`admin.test.ts:603-625, 711-720`). +- **IANA validation.** Both PUT and seed share `timezoneSchema` with the try/catch-based + `isValidIanaTimezone` (not `Intl.supportedValuesOf`, so `UTC` is accepted — Pitfall 2). Invalid + input returns 400 and writes nothing (`admin.test.ts:684-701`). +- **Broker async correctness.** `getHouseholdTimezone` is awaited before the all-day loop in + `reminderScheduler.ts:250`; the outbox resolver is awaited inside each all-day branch. No + un-awaited promises or new timer/handle leaks. +- **D-07 boundary guard.** No changes to `eventDateTime.ts` / `hydrateEvents.ts`; the browser-local + write/display path is untouched. AdminPage uses `Intl…resolvedOptions().timeZone` only for the + "Use detected" affordance and never auto-writes (D-03 respected). -## Info - -### IN-01: `isValidIanaTimezone` accepts non-canonical zones Intl tolerates - -**File:** `apps/api/src/lib/householdTimezone.ts:48-55` -**Issue:** `Intl.DateTimeFormat(undefined, { timeZone: tz })` accepts inputs beyond the canonical IANA set the picker offers (e.g. legacy aliases like `Etc/GMT+5`, or case-insensitive `utc`). This is *correct and intentional* for the security gate (it rejects garbage, which is all that matters before the parameterized write), and the comment correctly explains why `Intl.supportedValuesOf` is avoided. Noting only that the stored value may not match a `datalist` option exactly, which is harmless. No fix required; documenting the accepted-set breadth would help future readers. -**Fix:** Optional — add a one-line note that any zone Intl accepts is storable, not only `supportedValuesOf` entries. - -### IN-02: Detected-zone affordance hidden once input matches detected zone - -**File:** `apps/pwa/src/routes/AdminPage.tsx:405` -**Issue:** `detectedTz && detectedTz !== effectiveTimezoneInput` hides the "Use detected" button as soon as the input equals the detected zone. Combined with WR-01, a first-run user whose fallback already equals their browser zone sees neither an enabled Save nor the detected affordance — there is no single tap to make the correct value explicit. Resolving WR-01 removes the dead-end; this is just the contributing display condition. -**Fix:** No change needed once WR-01 is fixed. - -### IN-03: Broker reads the timezone once per tick / per outbox item (acceptable, worth a note) - -**File:** `apps/api/src/broker/reminderScheduler.ts:250`, `apps/api/src/broker/outboxWorker.ts:504,612` -**Issue:** The scheduler reads `getHouseholdTimezone(db)` once per tick (good — hoisted above the `allDayRows` loop). The outbox worker reads it once per processed all-day create/update item. Both are correct (no caching bug, picks up admin changes promptly) and async ordering is sound — `await` completes before `computeAlertInstantUtc` consumes `tz`. Per-item reads in the worker are a minor extra query but well within scope and not a performance concern at household scale (performance is out of v1 review scope regardless). -**Fix:** None required. If desired later, hoist the worker read to once per drain batch. - -### IN-04: `
` relies on implicit region role for e2e selectors - -**File:** `apps/pwa/src/routes/AdminPage.tsx:332`; `apps/pwa/e2e/timezone-verify.spec.ts:17` -**Issue:** The e2e spec selects `getByRole('region', { name: 'Timezone' })`. A `
` only exposes the `region` role when it has an accessible name — which `aria-label` provides here, so the selectors are valid. This is correct; flagged only because the coupling is implicit (removing the `aria-label` would silently break both the a11y affordance and the e2e suite). -**Fix:** None required; keep the `aria-label`. +All reviewed files meet quality standards. No actionable issues remain. --- -## Test Coverage Assessment - -Coverage is strong and behavior-pinned: -- **householdTimezone.test.ts** covers all four `getHouseholdTimezone` branches (stored / TZ env / Intl / null-value fall-through) and `isValidIanaTimezone` valid+invalid incl. the `UTC` Pitfall-2 case. -- **admin.test.ts** covers access control (403) for GET/PUT, the PUT→GET round-trip, UTC acceptance, invalid-zone 400 + no-write assertion, seed-when-unset, and seed-no-overwrite (D-03). - -Gaps (not blocking, recommend adding): -- No test for `POST /config/timezone/seed` returning the `seeded: boolean` flag value explicitly (only the stored value is asserted). -- No test asserting `POST seed` 403 for non-admins (GET and PUT are covered; seed is the same guard but untested). -- No test for the WR-01 UI dead-end (admin saving the displayed default to make it explicit) — add once WR-01 is fixed. -- Broker rewire tests (reminderScheduler/outboxWorker) were not re-read in full here; confirm they assert the stored value is actually threaded into `computeAlertInstantUtc` (not just that the accessor is called). - ---- - -_Reviewed: 2026-06-15T02:49:55Z_ +_Reviewed: 2026-06-15T04:30:00Z_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_