--- 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_